import json from pathlib import Path from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict from lead_scoring.cli.contract_pack import ( API_MAJOR_VERSION, CONTRACT_PACK_VERSION, MARKER_CLOSE, MARKER_OPEN, canonical_json, generate_contract_files, run_contract_pack_cli, validate_fixture_corpus, ) from lead_scoring.cli.webhook_contract import generate_contract_files as generate_webhook_files from lead_scoring.schemas.errors import ErrorTaxonomy from lead_scoring.schemas.lineage import ScoringRunLineage from lead_scoring.schemas.outcomes import OutcomeTaxonomy from lead_scoring.schemas.review import LeadScoringReviewedEvent, ReviewResponse from lead_scoring.schemas.scoring_config import ScoringConfig, ScoringConfigVersionSelection from lead_scoring.schemas.scoring_run import ( CreateScoringRunRequest, CreateScoringRunResponse, ScoringRunResponse, ) from lead_scoring.schemas.snapshot import CanonicalSnapshot from lead_scoring.schemas.webhook import ( LeadScoringTerminalEvent, WebhookIngestErrorResponse, ) class LegacyTerminalEvent(BaseModel): """Consumer model tối giản mô phỏng reader cũ tương thích additive.""" model_config = ConfigDict(extra="ignore") event_id: UUID event_type: Literal["lead_scoring.run.terminal"] event_version: Literal["1.0.0"] tenant_id: str subject_ref: str scoring_config_version: str run_id: UUID run_created_at: str occurred_at: str data: dict def test_contract_pack_is_deterministic_and_versioned(): files = generate_contract_files() assert files == generate_contract_files() assert json.loads(files["contract.json"])["contract_pack_version"] == CONTRACT_PACK_VERSION manifest = json.loads(files["contract.json"]) assert manifest["api_major_version"] == API_MAJOR_VERSION assert manifest["release_status"] == "pre-release" assert set(manifest["profiles"]) == { "canonical_snapshot", "scoring_config", "admission", "polling", "review", "lineage", "webhook", "errors", } assert all(profile["version"] == "1.0.0" for profile in manifest["profiles"].values()) assert "openapi.json" in files assert "SHA256SUMS" in files assert ( files["fixtures/tenant-unavailable.json"] == generate_webhook_files()["fixtures/tenant-unavailable.json"] ) def test_contract_schemas_and_fixture_corpus_use_pydantic_models(): files = generate_contract_files() assert json.loads(files["schemas/canonical-snapshot.schema.json"]) == ( CanonicalSnapshot.model_json_schema() ) assert ( json.loads(files["schemas/scoring-config.schema.json"]) == ScoringConfig.model_json_schema() ) assert json.loads(files["schemas/scoring-config-selection.schema.json"]) == ( ScoringConfigVersionSelection.model_json_schema() ) assert json.loads(files["schemas/admission-request.schema.json"]) == ( CreateScoringRunRequest.model_json_schema() ) assert json.loads(files["schemas/admission-response.schema.json"]) == ( CreateScoringRunResponse.model_json_schema() ) assert json.loads(files["schemas/polling-response.schema.json"]) == ( ScoringRunResponse.model_json_schema() ) assert ( json.loads(files["schemas/review-response.schema.json"]) == ReviewResponse.model_json_schema() ) assert json.loads(files["schemas/lineage.schema.json"]) == ScoringRunLineage.model_json_schema() assert json.loads(files["schemas/terminal-event.schema.json"]) == ( LeadScoringTerminalEvent.model_json_schema() ) assert json.loads(files["schemas/reviewed-event.schema.json"]) == ( LeadScoringReviewedEvent.model_json_schema() ) ErrorTaxonomy.model_validate(json.loads(files["error-taxonomy.json"])) OutcomeTaxonomy.model_validate(json.loads(files["outcome-taxonomy.json"])) validate_fixture_corpus(files) assert json.loads(files["fixtures/snapshot.json"])["version"] == "1.0.0" assert json.loads(files["fixtures/admission-request.json"])["subject_ref"] == "ems-lead-1001" ScoringConfigVersionSelection.model_validate( json.loads(files["fixtures/scoring-config-selection.json"]) ) assert json.loads(files["fixtures/review-override-request.json"])["override_score"] == 90 WebhookIngestErrorResponse.model_validate(json.loads(files["fixtures/tenant-unavailable.json"])) def test_marker_fixtures_cover_shared_merge_cases(): files = generate_contract_files() names = { "plain-description-append", "empty-description-append", "single-valid-block-replace", "orphan-opening", "orphan-closing", "nested-block", "overlapping-block", "multiple-complete-blocks", "unicode-multiline-preservation", "deleted-block-followed-by-append", "failed-run-no-render", } fixtures = {name: json.loads(files[f"fixtures/marker/{name}.json"]) for name in names} assert MARKER_OPEN in fixtures["plain-description-append"]["expected_description"] assert MARKER_CLOSE in fixtures["plain-description-append"]["expected_description"] assert fixtures["empty-description-append"]["current_description"] == "" assert fixtures["empty-description-append"]["expected_description"].startswith(MARKER_OPEN) assert fixtures["unicode-multiline-preservation"]["writeback"] is True assert fixtures["unicode-multiline-preservation"]["current_description"].startswith( "Khách hàng: Nguyễn Ánh 😊" ) malformed = [ fixtures[name] for name in ( "orphan-opening", "orphan-closing", "nested-block", "overlapping-block", "multiple-complete-blocks", ) ] assert all(item["expected_error"] == "marker_block_malformed" for item in malformed) assert all(item["expected_description"] is None for item in malformed) assert all(item["writeback"] is False for item in malformed) assert fixtures["failed-run-no-render"]["marker_input"] is None assert fixtures["failed-run-no-render"]["writeback"] is False def test_fixture_index_declares_shared_provider_consumer_corpus(): files = generate_contract_files() index = json.loads(files["fixture-index.json"]) assert index["provider_validation"] == "validate_fixture_corpus" assert index["consumer_validation"] assert all(item["role"] == "provider_consumer" for item in index["positive"]) assert {item["path"] for item in index["marker"]} == { "fixtures/marker/deleted-block-followed-by-append.json", "fixtures/marker/empty-description-append.json", "fixtures/marker/failed-run-no-render.json", "fixtures/marker/multiple-complete-blocks.json", "fixtures/marker/nested-block.json", "fixtures/marker/orphan-closing.json", "fixtures/marker/orphan-opening.json", "fixtures/marker/overlapping-block.json", "fixtures/marker/plain-description-append.json", "fixtures/marker/single-valid-block-replace.json", "fixtures/marker/unicode-multiline-preservation.json", } assert all(item["role"] == "provider_consumer" for item in index["marker"]) assert {item["path"] for item in index["negative"]} == { "fixtures/negative/snapshot-future-message.json", "fixtures/negative/admission-subject-mismatch.json", "fixtures/negative/review-override-out-of-range.json", "fixtures/negative/event-missing-version.json", } def test_additive_fixture_remains_compatible_with_previous_consumer(): files = generate_contract_files() current = json.loads(files["fixtures/webhook/completed.json"]) legacy = LegacyTerminalEvent.model_validate({**current, "new_optional_field": "ignored"}) assert legacy.event_type == "lead_scoring.run.terminal" assert legacy.run_id == UUID(current["run_id"]) def test_contract_pack_cli_generates_checks_and_rejects_drift(tmp_path: Path): output_dir = tmp_path / "contract-pack" assert run_contract_pack_cli(["--output", str(output_dir)]) == 0 assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 0 assert run_contract_pack_cli(["--output", str(output_dir), "--validate-fixtures"]) == 0 fixture = output_dir / "fixtures" / "snapshot.json" fixture.write_text(canonical_json({"stale": True}), encoding="utf-8") assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 1 assert run_contract_pack_cli(["--output", str(output_dir)]) == 0 (output_dir / "unexpected.json").write_text("{}\n", encoding="utf-8") assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 1