diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index 59cee34..dd4873e 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -1,28 +1,208 @@ +import asyncio from pathlib import Path +from typing import Any +import pytest + +from a2a_pack import LocalRunContext, PlatformUserAuth, UploadedFile from a2a_pack.cli.local import load_local_project +from a2a_pack.workspace import LocalWorkspaceClient +import agent as quote_agent +from agent import QuoteJudgeStudioV1 ROOT = Path(__file__).resolve().parents[1] +SUCCESS_ARGS = { + "comparison_id": "studio-quote-v1", + "quotes": [ + { + "delivery_days": 12, + "quantity": 500, + "unit_price": 10.75, + "vendor": "Acme Bearings", + "warranty_months": 12, + }, + { + "delivery_days": 8, + "quantity": 500, + "unit_price": 9.1, + "vendor": "Beta Industrial", + "warranty_months": 18, + }, + ], + "weights": {"delivery": 30, "price": 50, "warranty": 20}, +} + + +def ctx() -> LocalRunContext[PlatformUserAuth]: + return LocalRunContext( + auth=PlatformUserAuth(sub="user-123", user_id=123, email="buyer@example.com"), + task_id="test-task", + ) + 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") + browser_client = (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 "QuoteJudge Studio" in frontend + assert "compare_quotes_browser_upload" in frontend + assert "data_base64" in browser_client + assert "DATABASE_URL" not in frontend + browser_client + assert ".svc.cluster.local" not in frontend + browser_client 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() + assert card.version == "0.1.0" + skill_names = {skill.name for skill in card.skills} + assert {"compare_quotes", "get_comparison", "compare_quotes_file", "compare_quotes_browser_upload"} <= skill_names databases = card.runtime.platform_resources.databases assert databases, "live Agent Card must declare its managed database" + assert databases[0].name == "quote-judge-studio-v1-data" + assert databases[0].scope == "user" assert databases[0].migrations is not None assert databases[0].migrations.path == "db/migrations" + assert card.runtime.pricing.caller_pays_llm is False + + compare_schema = next(skill.input_schema for skill in card.skills if skill.name == "compare_quotes") + assert set(compare_schema["required"]) == {"comparison_id", "quotes", "weights"} + assert compare_schema["properties"]["quotes"]["type"] == "array" + + upload_schema = next(skill.input_schema for skill in card.skills if skill.name == "compare_quotes_file") + assert upload_schema["properties"]["document"]["x-a2a-file-upload"]["max_bytes"] == quote_agent.MAX_UPLOAD_BYTES + + +@pytest.mark.asyncio +async def test_success_failure_reload_and_receipt(monkeypatch): + saved: dict[tuple[str, str], dict[str, Any]] = {} + receipts: list[dict[str, Any]] = [] + + async def fake_save(tenant: str, result: dict[str, Any]) -> None: + saved[(tenant, result["comparison_id"])] = result + + async def fake_load(tenant: str, comparison_id: str) -> dict[str, Any] | None: + return saved.get((tenant, comparison_id)) + + async def fake_receipt(ctx, skill_name, comparison_id, status, payload): + receipts.append({"skill_name": skill_name, "comparison_id": comparison_id, "status": status, "payload": payload}) + + monkeypatch.setattr(quote_agent, "_save_comparison", fake_save) + monkeypatch.setattr(quote_agent, "_load_comparison", fake_load) + monkeypatch.setattr(quote_agent, "_persist_receipt_best_effort", fake_receipt) + + app = QuoteJudgeStudioV1() + success = await app.local_invoke("compare_quotes", auth=ctx().auth, **SUCCESS_ARGS) + assert success["ok"] is True + assert success["comparison_id"] == "studio-quote-v1" + assert success["recommendation"]["vendor"] == "Beta Industrial" + assert success["execution_receipt"]["persisted"] is True + + reload = await app.local_invoke("get_comparison", auth=ctx().auth, comparison_id="studio-quote-v1") + assert reload["ok"] is True + assert reload["recommendation"]["vendor"] == "Beta Industrial" + + failure_args = { + "comparison_id": "studio-quote-invalid", + "quotes": [ + { + "delivery_days": 5, + "quantity": 1, + "unit_price": 10, + "vendor": "Only Vendor", + "warranty_months": 6, + } + ], + "weights": {"delivery": 30, "price": 50, "warranty": 20}, + } + failure = await app.local_invoke("compare_quotes", auth=ctx().auth, **failure_args) + assert failure["ok"] is False + assert failure["code"] == "at_least_two_quotes_required" + assert any(r["skill_name"] == "compare_quotes" and r["status"] == "ok" for r in receipts) + + +@pytest.mark.asyncio +async def test_browser_upload_bridge(monkeypatch): + async def fake_save(tenant: str, result: dict[str, Any]) -> None: + return None + + async def fake_receipt(ctx, skill_name, comparison_id, status, payload): + return None + + monkeypatch.setattr(quote_agent, "_save_comparison", fake_save) + monkeypatch.setattr(quote_agent, "_persist_receipt_best_effort", fake_receipt) + + csv_text = "vendor,unit_price,quantity,delivery_days,warranty_months\nAcme Bearings,10.75,500,12,12\nBeta Industrial,9.1,500,8,18\n" + import base64 + + app = QuoteJudgeStudioV1() + result = await app.local_invoke( + "compare_quotes_browser_upload", + auth=ctx().auth, + comparison_id="studio-quote-v1", + weights={"delivery": 30, "price": 50, "warranty": 20}, + documents=[ + { + "filename": "quotes.csv", + "media_type": "text/csv", + "data_base64": base64.b64encode(csv_text.encode()).decode(), + } + ], + ) + assert result["ok"] is True + assert result["recommendation"]["vendor"] == "Beta Industrial" + + bad = await app.local_invoke( + "compare_quotes_browser_upload", + auth=ctx().auth, + comparison_id="studio-quote-v1", + weights={"delivery": 30, "price": 50, "warranty": 20}, + documents=[{"filename": "bad.csv", "media_type": "text/csv", "data_base64": "not base64"}], + ) + assert bad["ok"] is False + assert bad["code"] == "invalid_base64_upload" + + +@pytest.mark.asyncio +async def test_external_file_upload_path(monkeypatch): + async def fake_save(tenant: str, result: dict[str, Any]) -> None: + return None + + async def fake_receipt(ctx, skill_name, comparison_id, status, payload): + return None + + monkeypatch.setattr(quote_agent, "_save_comparison", fake_save) + monkeypatch.setattr(quote_agent, "_persist_receipt_best_effort", fake_receipt) + + workspace = LocalWorkspaceClient( + { + "uploads/quotes.csv": b"vendor,unit_price,quantity,delivery_days,warranty_months\nAcme Bearings,10.75,500,12,12\nBeta Industrial,9.1,500,8,18\n" + }, + access=QuoteJudgeStudioV1.workspace_access, + ) + local_ctx = LocalRunContext( + auth=ctx().auth, + workspace=workspace, + task_id="file-test", + ) + result = await QuoteJudgeStudioV1().invoke( + "compare_quotes_file", + local_ctx, + comparison_id="studio-quote-v1", + weights={"delivery": 30, "price": 50, "warranty": 20}, + document=UploadedFile(path="uploads/quotes.csv", filename="quotes.csv", media_type="text/csv", size_bytes=128), + ) + assert result["ok"] is True + assert result["recommendation"]["vendor"] == "Beta Industrial"