Files
Jyotisha/tests/test_personal_report_contract.py

319 lines
13 KiB
Python

"""ReportDocument v1 contract validator tests (Python side).
Semantics are shared with contracts/personal-report/report-document.v1.schema.json
and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the
runtime-enforced rules that JSON Schema draft-07 cannot express and prove the
validator never raises on arbitrary/malformed input.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.personal_report_contract import (
FAILURE_CODES,
MAX_SERIALIZED_BYTES,
compute_evidence_hash,
is_valid_report_document,
load_report_document,
main,
parse_report_document_json,
serialized_bytes,
validate_report_document,
)
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json"
SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql"
LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql"
@pytest.fixture(scope="module")
def fixture() -> dict:
return load_report_document(str(FIXTURE))
def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None:
result = validate_report_document(fixture)
assert result.valid, result.errors
# evidenceHash is a deterministic recomputation, not a model self-report.
assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture)
assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES
def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None:
# The fixture is read byte-for-byte by the TS tests too; both sides must
# compute the same sha256 over the canonical evidence appendix.
assert fixture["provenance"]["evidenceHash"] == (
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4"
)
def test_report_document_v1_keeps_legacy_skill_identity_compatibility(fixture: dict) -> None:
assert "skillName" not in fixture["provenance"]
assert "skillVersion" not in fixture["provenance"]
assert validate_report_document(fixture).valid
def test_optional_skill_identity_accepts_registry_values_and_rejects_malformed_values(fixture: dict) -> None:
current = json.loads(json.dumps(fixture))
current["provenance"]["skillName"] = "jyotish-vedic-astrology"
current["provenance"]["skillVersion"] = "6.9.14"
assert validate_report_document(current).valid
bad_name = json.loads(json.dumps(fixture))
bad_name["provenance"]["skillName"] = "Bad Package"
assert validate_report_document(bad_name).valid is False
bad_version = json.loads(json.dumps(fixture))
bad_version["provenance"]["skillVersion"] = "v6"
assert validate_report_document(bad_version).valid is False
@pytest.mark.parametrize(
"malformed",
[
None,
42,
"text",
[],
{},
{"schemaVersion": "report_document.v1"},
{"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}},
{"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]},
{"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"},
],
)
def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None:
result = validate_report_document(malformed)
assert result.valid is False
assert isinstance(result.errors, list)
def test_arbitrary_json_text_never_raises() -> None:
for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]:
result = parse_report_document_json(text)
assert result.valid is False
def test_charts_require_exactly_one_d1(fixture: dict) -> None:
without_d1 = json.loads(json.dumps(fixture))
without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"]
result = validate_report_document(without_d1)
assert result.valid is False
assert any("exactly one D1" in error for error in result.errors)
two_d1 = json.loads(json.dumps(fixture))
two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0])))
result = validate_report_document(two_d1)
assert result.valid is False
assert any("duplicate chart id" in error for error in result.errors)
assert any("exactly one D1" in error for error in result.errors)
def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None:
incomplete = json.loads(json.dumps(fixture))
incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11]
result = validate_report_document(incomplete)
assert result.valid is False
assert any("all twelve house numbers" in error for error in result.errors)
def test_duplicate_house_numbers_rejected(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["charts"][0]["houses"][11]["houseNumber"] = 1
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate houseNumber" in error for error in result.errors)
def test_longitude_is_half_open_interval(fixture: dict) -> None:
at_360 = json.loads(json.dumps(fixture))
at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0
result = validate_report_document(at_360)
assert result.valid is False
assert any("longitudeDegrees" in error for error in result.errors)
near_360 = json.loads(json.dumps(fixture))
near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999
near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360)
assert validate_report_document(near_360).valid
def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"]
duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated)
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate evidence id" in error for error in result.errors)
def test_dangling_evidence_refs_rejected(fixture: dict) -> None:
dangling = json.loads(json.dumps(fixture))
dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"]
result = validate_report_document(dangling)
assert result.valid is False
assert any("unknown evidence id" in error for error in result.errors)
def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None:
tampered = json.loads(json.dumps(fixture))
tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值"
# Self-reported hash left unchanged: validator must recompute and reject.
result = validate_report_document(tampered)
assert result.valid is False
assert any("does not match computed evidence hash" in error for error in result.errors)
def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None:
deterministic = json.loads(json.dumps(fixture))
deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。"
result = validate_report_document(deterministic)
assert result.valid is False
assert any("blocked section contains deterministic prediction" in error for error in result.errors)
non_deterministic = json.loads(json.dumps(fixture))
non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。"
non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic)
assert validate_report_document(non_deterministic).valid
@pytest.mark.parametrize(
"poison",
[
"<script>alert(1)</script>",
"javascript:alert(1)",
"file:///Users/jesse/private/chart.json",
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
"onerror=alert(1)",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
"node:internal/modules/cjs/loader",
"Traceback (most recent call last)",
"__dirname/secret",
"C:\\Users\\jesse\\chart.json",
"tool_call_id: call_123",
],
)
def test_forbidden_content_rejected(fixture: dict, poison: str) -> None:
poisoned = json.loads(json.dumps(fixture))
poisoned["disclaimer"] = poison
result = validate_report_document(poisoned)
assert result.valid is False
assert any("forbidden content" in error for error in result.errors)
def test_serialization_size_cap(fixture: dict) -> None:
oversized = json.loads(json.dumps(fixture))
oversized["disclaimer"] = "" * (MAX_SERIALIZED_BYTES)
result = validate_report_document(oversized)
assert result.valid is False
assert any("exceeding" in error for error in result.errors)
def test_strict_keys_missing_and_extra(fixture: dict) -> None:
missing = json.loads(json.dumps(fixture))
del missing["disclaimer"]
result = validate_report_document(missing)
assert result.valid is False
assert any("missing required keys" in error for error in result.errors)
extra = json.loads(json.dumps(fixture))
extra["disclaimer"] = extra["disclaimer"]
extra["subject"]["hometown"] = "上海"
result = validate_report_document(extra)
assert result.valid is False
assert any("unexpected keys" in error for error in result.errors)
def test_json_object_key_order_is_not_validated(fixture: dict) -> None:
# JSON objects are unordered; fixed reader order is a display contract of
# the typed fields, never a key-order condition.
reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))}
assert validate_report_document(reordered).valid
def test_failure_code_enum_matches_both_migrations() -> None:
for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION):
sql = path.read_text(encoding="utf-8")
for code in FAILURE_CODES:
assert f"'{code}'" in sql, f"{code} missing from {path.name}"
assert sql.count("failure_code in") == 1
# Both migrations share the same stable enum.
supabase_codes = set(FAILURE_CODES)
local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8")
assert all(f"'{code}'" in local_sql for code in supabase_codes)
def test_cli_exit_codes(fixture: dict) -> None:
assert main([str(FIXTURE)]) == 0
assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1
assert main([]) == 2
assert main([str(ROOT / "does-not-exist.json")]) == 2
def test_validator_accepts_synthetic_producer_output() -> None:
# A minimal-but-complete document produced without the fixture must pass.
from scripts.personal_report_contract import (
BIRTH_TIME_STATUSES,
CLAIM_STATUSES,
PRESENTATION_MODES,
REPORT_TYPES,
SCHEMA_VERSION,
)
houses = [
{"houseNumber": number, "sign": "狮子座", "occupants": []}
for number in range(1, 13)
]
document = {
"schemaVersion": SCHEMA_VERSION,
"reportId": "00000000-0000-4000-8000-000000000001",
"reportType": REPORT_TYPES[0],
"presentationMode": PRESENTATION_MODES[0],
"generatedAt": "2026-08-06T08:00:00Z",
"subject": {
"displayName": "合成用户",
"birthTimeStatus": BIRTH_TIME_STATUSES[0],
"birthPlaceLabel": "合成地点",
},
"provenance": {
"skillName": "jyotish-vedic-astrology",
"skillVersion": "6.9.14",
"skillSourceCommit": None,
"skillSnapshotSha256": "c" * 64,
"calculationHash": "d" * 64,
"evidenceHash": "0" * 64,
"reportContractVersion": "1",
},
"executiveSummary": {
"headline": "摘要标题",
"summary": "摘要正文。",
"priorities": ["优先事项"],
"overallClaimStatus": CLAIM_STATUSES[0],
},
"charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}],
"thematicNarrative": [],
"evidenceAppendix": {
"expandedByDefault": False,
"techniqueAudit": [
{
"id": "ev-audit",
"techniqueId": "d1_chart",
"techniqueName": "D1 本命盘",
"status": "verified",
"used": True,
}
],
"conflicts": [],
"calculationEvidence": [],
"blockedTechniques": [],
},
"disclaimer": "仅供研究参考。",
}
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
assert validate_report_document(document).valid