test: add training API contract smoke
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user