test: add training API contract smoke

This commit is contained in:
2026-04-30 21:15:31 -04:00
parent 1e1a98391e
commit 2abe4620ef
@@ -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()