diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index 59cee34..d5705cf 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -1,28 +1,131 @@ +import asyncio +import json +import os from pathlib import Path +from unittest.mock import patch +from a2a_pack import LocalRunContext, PlatformUserAuth from a2a_pack.cli.local import load_local_project +from agent import ( + InvoiceGuardStudioV1, + _parse_browser_documents, + _review_payload, +) ROOT = Path(__file__).resolve().parents[1] +SUCCESS_INVOICES = [ + {"invoice_number": "INV-100", "subtotal": 90, "tax": 10, "total": 100, "vendor": "Acme"}, + {"invoice_number": "INV-100", "subtotal": 90, "tax": 10, "total": 100, "vendor": "Acme"}, + {"invoice_number": "INV-200", "subtotal": 50, "tax": 5, "total": 80, "vendor": "Beta"}, +] + def test_full_stack_product_contract(): manifest = (ROOT / "a2a.yaml").read_text(encoding="utf-8") source = (ROOT / "agent.py").read_text(encoding="utf-8") frontend = (ROOT / "frontend" / "src" / "App.jsx").read_text(encoding="utf-8") + bridge = (ROOT / "frontend" / "src" / "a2a.js").read_text(encoding="utf-8") assert "frontend:" in manifest and "mount: /app" in manifest + assert "public: false" in manifest assert "resources:" in manifest and "databases:" in manifest assert "migrations:" in manifest and "db/migrations" in manifest assert "PlatformUserAuth" in source assert "AgentPlatformResources" in source and "AgentDatabase(" in source - assert "Skill runner" not in frontend, "replace the generic scaffold with the product workflow" + assert "LLMProvisioning" not in source and "ctx.llm" not in source + assert "Skill runner" not in frontend + assert "InvoiceGuard Studio" in frontend + assert "fileToBrowserDocument" in bridge + assert "A2A_LITELLM_KEY" not in source + frontend + bridge + assert "DATABASE_URL" not in frontend + bridge migrations = list((ROOT / "db" / "migrations").glob("*.sql")) assert migrations and all(path.read_text(encoding="utf-8").strip() for path in migrations) card = load_local_project(ROOT).agent_cls().card() - databases = card.runtime.platform_resources.databases - assert databases, "live Agent Card must declare its managed database" - assert databases[0].migrations is not None - assert databases[0].migrations.path == "db/migrations" + skills = {skill.name: skill for skill in card.skills} + for name in {"review_invoices", "get_invoice_case", "record_decision", "review_invoice_uploads", "review_invoice_file"}: + assert name in skills + assert set(skills["review_invoices"].input_schema["required"]) == {"case_id", "invoices"} + assert "document" in skills["review_invoice_file"].input_schema["properties"] + assert card.runtime.pricing.caller_pays_llm is False + assert card.runtime.platform_resources.databases + assert card.runtime.platform_resources.databases[0].migrations.path == "db/migrations" + + +def test_review_success_fixture_is_deterministic(): + result = _review_payload("studio-invoice-v1", SUCCESS_INVOICES) + assert result["ok"] is True + assert result["case_id"] == "studio-invoice-v1" + assert result["duplicate_count"] == 1 + assert result["total_mismatch_count"] == 1 + assert result["duplicates"][0]["invoice_number"] == "INV-100" + assert result["total_mismatches"][0]["invoice_number"] == "INV-200" + assert result["total_mismatches"][0]["expected_total"] == 55.0 + + +def test_incomplete_invoice_failure_fixture(): + result = _review_payload("studio-invoice-invalid", [{"invoice_number": "INV-X", "subtotal": 10, "tax": 1}]) + assert result["ok"] is False + assert result["code"] == "incomplete_invoice" + assert "vendor" in result["message"] and "total" in result["message"] + + +def test_browser_upload_bridge_bounds_and_parses_json(): + payload = json.dumps({"invoices": SUCCESS_INVOICES}).encode() + import base64 + + result = _parse_browser_documents([ + {"filename": "fixture.json", "media_type": "application/json", "data_base64": base64.b64encode(payload).decode()} + ]) + assert result["ok"] is True + assert len(result["invoices"]) == 3 + + bad = _parse_browser_documents([ + {"filename": "fixture.json", "media_type": "application/json", "data_base64": "not-base64!!!"} + ]) + assert bad["ok"] is False + assert bad["code"] == "invalid_base64" + + +def test_acceptance_calls_with_mocked_persistence_reload_and_decision(): + agent = InvoiceGuardStudioV1() + auth = PlatformUserAuth(sub="stable-sub", user_id=123, email="user@example.test") + ctx = LocalRunContext(auth=auth) + store = {} + + def persist(tenant, case, source): + receipt = {"receipt_id": "invoice_guard:studio-invoice-v1:test", "kind": "production_execution_receipt"} + saved = {**case, "receipt": receipt, "saved_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-01T00:00:00+00:00"} + store[(tenant, case["case_id"])] = saved + return {"receipt": receipt, "saved_at": saved["saved_at"], "case": saved} + + def load(tenant, case_id): + return store.get((tenant, case_id)) + + def decision(tenant, case_id, payload): + case = store.get((tenant, case_id)) + if case is None: + return None + case = {**case, "decisions": [{**payload, "decided_at": "2026-01-01T00:00:01+00:00"}]} + store[(tenant, case_id)] = case + return case + + with patch("agent._persist_case_with_receipt", side_effect=persist), patch("agent._load_case", side_effect=load), patch("agent._persist_decision", side_effect=decision): + success = asyncio.run(agent.review_invoices(ctx, "studio-invoice-v1", SUCCESS_INVOICES)) + assert success["ok"] is True + assert success["duplicate_count"] == 1 + assert success["total_mismatch_count"] == 1 + assert success["receipt"]["kind"] == "production_execution_receipt" + + reload = asyncio.run(agent.get_invoice_case(ctx, "studio-invoice-v1")) + assert reload["ok"] is True + assert reload["case_id"] == "studio-invoice-v1" + assert reload["duplicate_count"] == 1 + assert reload["total_mismatch_count"] == 1 + + updated = asyncio.run(agent.record_decision(ctx, "studio-invoice-v1", {"decision_id": "d1", "decision": "hold", "note": "Investigate duplicate", "invoice_number": "INV-100"})) + assert updated["ok"] is True + assert updated["decisions"][0]["decision"] == "hold"