From 2abe4620ef9a3bda35ee4e17598a9c6f13c4d888 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 30 Apr 2026 21:15:31 -0400 Subject: [PATCH] test: add training API contract smoke --- .../test_training_public_api_contract_m8.py | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 tests/unit/test_training_public_api_contract_m8.py diff --git a/tests/unit/test_training_public_api_contract_m8.py b/tests/unit/test_training_public_api_contract_m8.py new file mode 100644 index 0000000..eecf4ff --- /dev/null +++ b/tests/unit/test_training_public_api_contract_m8.py @@ -0,0 +1,325 @@ +import json +import re +import unittest +from pathlib import Path +from unittest import mock + +from src.api import server +from src.api import training as training_api +from src.services.training import TrainingCaseService + + +ROOT = Path(__file__).resolve().parents[2] +TRAINING_PRODUCT_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md" +INTERNAL_FIELD = "answer" + "_key" +PRIVATE_MARKER = "trainer-only-choice" +PRIVATE_RUBRIC_MARKER = "trainer-only-rubric" + +DOCUMENTED_TRAINING_ROUTES = ( + ("GET", "/training/cases"), + ("GET", "/training/cases/{case_id}"), + ("GET", "/training/cases/export"), + ("POST", "/training/cases/import"), + ("POST", "/training/attempts"), + ("POST", "/training/attempts/{attempt_id}/annotations"), + ("POST", "/training/attempts/{attempt_id}/submit"), + ("GET", "/training/progress"), +) + + +class _TrainingRouteProbe: + def __init__(self) -> None: + self.calls = [] + + def dependencies(self) -> dict: + return { + "training_cases": self.training_cases, + "training_case": self.training_case, + "training_export_cases": self.training_export_cases, + "training_import_cases": self.training_import_cases, + "training_create_attempt": self.training_create_attempt, + "training_add_annotation": self.training_add_annotation, + "training_submit_attempt": self.training_submit_attempt, + "training_progress": self.training_progress, + } + + def training_cases(self, **kwargs): + self.calls.append(("training_cases", kwargs)) + return [{"id": 7}] + + def training_case(self, **kwargs): + self.calls.append(("training_case", kwargs)) + return {"id": kwargs["case_id"]} + + def training_export_cases(self, **kwargs): + self.calls.append(("training_export_cases", kwargs)) + return {"version": 1, "items": []} + + def training_import_cases(self, body): + self.calls.append(("training_import_cases", {"body": body})) + return {"version": 1, "imported": 1, "items": []} + + def training_create_attempt(self, body): + self.calls.append(("training_create_attempt", {"body": body})) + return {"id": 101} + + def training_add_annotation(self, attempt_id, body): + self.calls.append(("training_add_annotation", {"attempt_id": attempt_id, "body": body})) + return {"id": 202} + + def training_submit_attempt(self, attempt_id, body): + self.calls.append(("training_submit_attempt", {"attempt_id": attempt_id, "body": body})) + return {"id": attempt_id, "status": "submitted"} + + def training_progress(self, **kwargs): + self.calls.append(("training_progress", kwargs)) + return [{"user_id": kwargs["user_id"], "skill_tag": kwargs["skill_tag"]}] + + +class _ExecutionServiceStub: + def build_execution_dashboard_items(self, instrument_id: int, limit: int = 20) -> list: + return [] + + +class _PublicTrainingRepository: + def __init__(self) -> None: + self.get_case_args = None + self.created_cases = [] + self.created_tasks = [] + + def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100): + return [self._case_row(7)] + + def get_case(self, case_id: int, **kwargs): + self.get_case_args = { + "case_id": case_id, + "include_" + INTERNAL_FIELD: kwargs.get("include_" + INTERNAL_FIELD, False), + } + return self._case_row(case_id) + + def create_case(self, **kwargs): + self.created_cases.append(kwargs) + return 41 + + def add_task(self, **kwargs): + self.created_tasks.append(kwargs) + return 201 + + def _case_row(self, case_id: int) -> dict: + return { + "id": case_id, + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "skill_tag": "sweep", + "start_ts": "2026-04-01T09:00:00Z", + "decision_ts": "2026-04-01T10:00:00Z", + "end_ts": "2026-04-01T11:00:00Z", + "difficulty": "medium", + "status": "published", + "source_event_refs": [{"kind": "sweep_event", "id": 9}], + INTERNAL_FIELD: { + "answer": {"side": PRIVATE_MARKER}, + "rubric": [{"category": PRIVATE_RUBRIC_MARKER, "max_score": 5}], + }, + "meta": { + "title": "Public sweep practice", + "skill_tags": ["sweep", "liquidity_pool"], + }, + } + + +def _documented_routes() -> tuple: + text = TRAINING_PRODUCT_DOC.read_text(encoding="utf-8") + routes = [] + for method, path in re.findall(r"\|\s*(GET|POST)\s*\|\s*`([^`]+)`\s*\|", text): + if path.startswith("/training/"): + routes.append((method, path)) + return tuple(routes) + + +def _training_dependencies(service: TrainingCaseService) -> dict: + return { + "training_cases": lambda **kwargs: training_api.get_training_cases(service=service, **kwargs), + "training_case": lambda **kwargs: training_api.get_training_case(service=service, **kwargs), + "training_export_cases": lambda **kwargs: training_api.export_training_case_package(service=service, **kwargs), + "training_import_cases": lambda body: training_api.import_training_case_package(body, service=service), + "training_create_attempt": lambda body: training_api.create_training_attempt(body, service=service), + "training_add_annotation": lambda attempt_id, body: training_api.add_training_annotation(attempt_id, body, service=service), + "training_submit_attempt": lambda attempt_id, body: training_api.submit_training_attempt(attempt_id, body, service=service), + "training_progress": lambda **kwargs: training_api.get_training_progress(service=service, **kwargs), + } + + +def _public_import_package() -> dict: + return { + "version": 1, + "items": [ + { + "title": "Imported sweep practice", + "scenario": { + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "start_ts": "2026-04-01T09:00:00Z", + "decision_ts": "2026-04-01T10:00:00Z", + "end_ts": "2026-04-01T11:00:00Z", + "source_event_refs": [{"kind": "sweep_event", "id": 9}], + }, + "skill_tags": ["sweep", "liquidity_pool"], + "difficulty": "medium", + "answer": {"side": PRIVATE_MARKER}, + "rubric": [{"category": PRIVATE_RUBRIC_MARKER, "max_score": 5}], + "tasks": [ + { + "task_type": "multiple_choice", + "skill_tag": "sweep", + "prompt": "Classify the setup.", + "required_annotation_types": ["multiple_choice"], + "max_score": 5, + } + ], + } + ], + } + + +class TrainingPublicApiContractM8Tests(unittest.TestCase): + def test_documented_training_routes_resolve_to_expected_handlers(self) -> None: + self.assertEqual(_documented_routes(), DOCUMENTED_TRAINING_ROUTES) + + route_cases = ( + ( + "GET", + "/training/cases", + {"status": ["published"], "skill_tag": ["sweep"], "instrument_id": ["1"], "limit": ["8"]}, + None, + 200, + ("training_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}), + ), + ("GET", "/training/cases/7", {}, None, 200, ("training_case", {"case_id": 7})), + ( + "GET", + "/training/cases/export", + {"status": ["published"], "skill_tag": ["sweep"], "instrument_id": ["1"], "limit": ["8"]}, + None, + 200, + ("training_export_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}), + ), + ( + "POST", + "/training/cases/import", + {}, + {"version": 1, "items": [{"title": "stub"}]}, + 201, + ("training_import_cases", {"body": {"version": 1, "items": [{"title": "stub"}]}}), + ), + ( + "POST", + "/training/attempts", + {}, + {"case_id": 7, "user_id": "analyst"}, + 201, + ("training_create_attempt", {"body": {"case_id": 7, "user_id": "analyst"}}), + ), + ( + "POST", + "/training/attempts/101/annotations", + {}, + {"annotation_type": "zone", "payload": {"note": "context"}}, + 201, + ( + "training_add_annotation", + {"attempt_id": 101, "body": {"annotation_type": "zone", "payload": {"note": "context"}}}, + ), + ), + ( + "POST", + "/training/attempts/101/submit", + {}, + {"total_score": 9}, + 200, + ("training_submit_attempt", {"attempt_id": 101, "body": {"total_score": 9}}), + ), + ( + "GET", + "/training/progress", + {"user_id": ["analyst"], "skill_tag": ["sweep"], "limit": ["4"]}, + None, + 200, + ("training_progress", {"user_id": "analyst", "skill_tag": "sweep", "limit": 4}), + ), + ) + + self.assertEqual(len(route_cases), len(DOCUMENTED_TRAINING_ROUTES)) + for method, path, query, body, expected_status, expected_call in route_cases: + probe = _TrainingRouteProbe() + with self.subTest(method=method, path=path): + status, payload = server.route_request( + path, + query, + dependencies=probe.dependencies(), + method=method, + body=body, + ) + + self.assertEqual(status, expected_status) + self.assertIsInstance(payload, dict) + self.assertEqual(probe.calls, [expected_call]) + + def test_dependency_builder_exposes_training_handler_functions(self) -> None: + expected = { + "training_cases": training_api.get_training_cases, + "training_case": training_api.get_training_case, + "training_export_cases": training_api.export_training_case_package, + "training_import_cases": training_api.import_training_case_package, + "training_create_attempt": training_api.create_training_attempt, + "training_add_annotation": training_api.add_training_annotation, + "training_submit_attempt": training_api.submit_training_attempt, + "training_progress": training_api.get_training_progress, + } + + with mock.patch.object(server, "ExecutionService", _ExecutionServiceStub): + dependencies = server.build_api_dependencies() + + for name, handler in expected.items(): + with self.subTest(name=name): + self.assertIs(dependencies[name], handler) + + def test_public_training_serialization_omits_internal_solution_material(self) -> None: + repository = _PublicTrainingRepository() + service = TrainingCaseService(repository=repository) + dependencies = _training_dependencies(service) + + list_status, list_payload = server.route_request("/training/cases", {}, dependencies=dependencies) + detail_status, detail_payload = server.route_request("/training/cases/7", {}, dependencies=dependencies) + export_status, export_payload = server.route_request("/training/cases/export", {}, dependencies=dependencies) + import_status, import_payload = server.route_request( + "/training/cases/import", + {}, + dependencies=dependencies, + method="POST", + body=_public_import_package(), + ) + + self.assertEqual([list_status, detail_status, export_status, import_status], [200, 200, 200, 201]) + self.assertEqual(repository.get_case_args, {"case_id": 7, "include_" + INTERNAL_FIELD: False}) + self.assertEqual(len(repository.created_cases), 1) + self.assertIn(INTERNAL_FIELD, repository.created_cases[0]) + + serialized = json.dumps( + { + "list": list_payload, + "detail": detail_payload, + "export": export_payload, + "import": import_payload, + }, + sort_keys=True, + ) + for private_text in (INTERNAL_FIELD, PRIVATE_MARKER, PRIVATE_RUBRIC_MARKER): + with self.subTest(private_text=private_text): + self.assertNotIn(private_text, serialized) + + +if __name__ == "__main__": + unittest.main()