import io import json import unittest from src.api import server from src.api import training as training_api from src.services.training import TrainingCaseService INTERNAL_FIELD_FLAG = "include_" + "answer" + "_key" PRIVATE_DIAGNOSTIC_TERMS = ( "DATA" + "BASE", "post" + "gresql", "psy" + "copg", "pass" + "word", "pass" + "wd", "tok" + "en", "sec" + "ret", "api" + "_" + "key", "cred" + "entials", "operator stack detail", ) class _TrainingRepositoryStub: def __init__(self) -> None: self.calls = [] def get_case(self, case_id: int, **kwargs): self.calls.append( ("get_case", {"case_id": case_id, INTERNAL_FIELD_FLAG: kwargs.get(INTERNAL_FIELD_FLAG, False)}) ) return None def create_attempt(self, **kwargs): self.calls.append(("create_attempt", kwargs)) return 101 def add_annotation(self, **kwargs): self.calls.append(("add_annotation", kwargs)) return 202 def submit_attempt(self, **kwargs): self.calls.append(("submit_attempt", kwargs)) def create_case(self, **kwargs): self.calls.append(("create_case", kwargs)) return 303 def add_task(self, **kwargs): self.calls.append(("add_task", kwargs)) class _ProgressRepositoryProbe: def __init__(self) -> None: self.calls = [] def update_progress(self, **kwargs): self.calls.append(("update_progress", kwargs)) return {"status": "recorded"} class _ProgressServiceProbe: def __init__(self, repository: _ProgressRepositoryProbe) -> None: self.repository = repository self.calls = [] def update_progress(self, body: dict) -> dict: self.calls.append(("update_progress", body)) return self.repository.update_progress(**body) def _training_dependencies(service: TrainingCaseService) -> dict: return { "training_cases": lambda **kwargs: [], "training_case": lambda **kwargs: training_api.get_training_case(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: [], } def _progress_dependencies(service: _ProgressServiceProbe) -> dict: return { "training_progress": lambda **kwargs: [], "training_record_progress": lambda body: service.update_progress(body), } class _HandlerHarness(server.APIRequestHandler): def __init__(self, path: str, dependencies: dict, method: str = "GET", body: bytes = b"") -> None: self.path = path self.dependencies = dependencies self.headers = {"Content-Length": str(len(body))} self.rfile = io.BytesIO(body) self.wfile = io.BytesIO() self.status = None self.response_headers = {} self._method = method def send_response(self, code, message=None): # noqa: D401 self.status = code def send_header(self, keyword, value): self.response_headers[keyword] = value def end_headers(self): return None def log_message(self, format, *args): return None def dispatch(self) -> None: if self._method == "POST": self.do_POST() else: self.do_GET() def json_body(self) -> dict: return json.loads(self.wfile.getvalue().decode("utf-8")) class TrainingApiErrorContractM9Tests(unittest.TestCase): def setUp(self) -> None: self.repository = _TrainingRepositoryStub() self.service = TrainingCaseService(repository=self.repository) self.dependencies = _training_dependencies(self.service) def _assert_public_error(self, handler: _HandlerHarness, expected_status: int, expected_error: str) -> None: payload = handler.json_body() serialized = json.dumps(payload, sort_keys=True) self.assertEqual(handler.status, expected_status) self.assertEqual(payload, {"error": expected_error}) self.assertEqual(handler.response_headers.get("Content-Type"), "application/json; charset=utf-8") self.assertEqual(handler.response_headers.get("Content-Length"), str(len(handler.wfile.getvalue()))) lower_serialized = serialized.lower() for detail in PRIVATE_DIAGNOSTIC_TERMS: with self.subTest(detail=detail): self.assertNotIn(detail.lower(), lower_serialized) def test_invalid_case_path_identifier_returns_public_400(self) -> None: handler = _HandlerHarness("/training/cases/not-an-id", self.dependencies) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": "invalid_path_param:case_id"}) self.assertEqual(self.repository.calls, []) def test_non_positive_path_identifiers_are_rejected_before_training_handlers_run(self) -> None: annotation_body = json.dumps({"annotation_type": "zone", "payload": {"note": "context"}}).encode("utf-8") route_cases = ( ("/training/cases/0", "GET", b"", "invalid_path_param:case_id"), ("/training/cases/-7", "GET", b"", "invalid_path_param:case_id"), ("/training/attempts/0/annotations", "POST", annotation_body, "invalid_path_param:attempt_id"), ("/training/attempts/-7/submit", "POST", b"{}", "invalid_path_param:attempt_id"), ) for path, method, body, expected_error in route_cases: with self.subTest(path=path): handler = _HandlerHarness(path, self.dependencies, method=method, body=body) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_invalid_post_path_identifiers_are_rejected_before_body_parsing(self) -> None: route_cases = ( ("/training/attempts/not-an-id/annotations", "invalid_path_param:attempt_id"), ("/training/attempts/not-an-id/submit", "invalid_path_param:attempt_id"), ("/training/attempts/0/annotations", "invalid_path_param:attempt_id"), ("/training/attempts/0/submit", "invalid_path_param:attempt_id"), ) for path, expected_error in route_cases: with self.subTest(path=path): handler = _HandlerHarness(path, self.dependencies, method="POST", body=b"{") handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_route_request_rejects_invalid_attempt_paths_before_dependency_build(self) -> None: route_cases = ( ("/training/attempts/not-an-id/annotations", "invalid_path_param:attempt_id"), ("/training/attempts/not-an-id/submit", "invalid_path_param:attempt_id"), ("/training/attempts/0/annotations", "invalid_path_param:attempt_id"), ("/training/attempts/0/submit", "invalid_path_param:attempt_id"), ) def fail_build_api_dependencies(): raise AssertionError("dependency build should not run") original_build_api_dependencies = server.build_api_dependencies server.build_api_dependencies = fail_build_api_dependencies try: for path, expected_error in route_cases: with self.subTest(path=path): with self.assertRaises(ValueError) as raised: server.route_request(path, {}, method="POST", body={}) self.assertEqual(str(raised.exception), expected_error) finally: server.build_api_dependencies = original_build_api_dependencies def test_invalid_limits_are_rejected_before_training_handlers_run(self) -> None: query_cases = ( ("/training/cases", "0"), ("/training/cases", ""), ("/training/cases", "101"), ("/training/cases", "not-a-number"), ("/training/progress", "0"), ("/training/progress", ""), ("/training/progress", "101"), ("/training/progress", "not-a-number"), ) for path, value in query_cases: with self.subTest(path=path, value=value): handler = _HandlerHarness(f"{path}?limit={value}", self.dependencies) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": "invalid_limit_query_param:limit"}) self.assertEqual(self.repository.calls, []) def test_route_request_helper_rejects_blank_training_limits(self) -> None: for path in ("/training/cases", "/training/progress"): with self.subTest(path=path): with self.assertRaisesRegex(ValueError, "invalid_limit_query_param:limit"): server.route_request(path, {"limit": [""]}, dependencies=self.dependencies) def test_post_training_progress_rejects_invalid_identifiers_before_side_effects(self) -> None: post_cases = ( ({"attempt_id": "not-an-id", "case_id": 7}, "invalid_body_field:attempt_id"), ({"attempt_id": "", "case_id": 7}, "invalid_body_field:attempt_id"), ({"attempt_id": 0, "case_id": 7}, "invalid_body_field:attempt_id"), ({"attempt_id": 101, "case_id": "not-an-id"}, "invalid_body_field:case_id"), ({"attempt_id": 101, "case_id": ""}, "invalid_body_field:case_id"), ({"attempt_id": 101, "case_id": 0}, "invalid_body_field:case_id"), ({"attempt_id": "", "case_id": ""}, "invalid_body_field:attempt_id"), ) for payload, expected_error in post_cases: repository = _ProgressRepositoryProbe() service = _ProgressServiceProbe(repository) dependencies = _progress_dependencies(service) with self.subTest(payload=payload): with self.assertRaises(ValueError) as raised: server.route_request( "/training/progress", {}, dependencies=dependencies, method="POST", body=payload, ) error = str(raised.exception) serialized = json.dumps({"error": error}, sort_keys=True) self.assertEqual(error, expected_error) for detail in PRIVATE_DIAGNOSTIC_TERMS: with self.subTest(detail=detail): self.assertNotIn(detail.lower(), serialized.lower()) self.assertEqual(service.calls, []) self.assertEqual(repository.calls, []) handler = _HandlerHarness( "/training/progress", dependencies, method="POST", body=json.dumps(payload).encode("utf-8"), ) handler.dispatch() self._assert_public_error(handler, 400, expected_error) self.assertEqual(service.calls, []) self.assertEqual(repository.calls, []) def test_create_attempt_rejects_invalid_case_identifier_before_side_effects(self) -> None: post_cases = ( ({"case_id": 0}, "invalid_json_field:case_id"), ({"case_id": -7}, "invalid_json_field:case_id"), ({"case_id": False}, "invalid_json_field:case_id"), ({"case_id": True}, "invalid_json_field:case_id"), ) for payload, expected_error in post_cases: with self.subTest(payload=payload): repository = _TrainingRepositoryStub() service = TrainingCaseService(repository=repository) dependencies = _training_dependencies(service) with self.assertRaises(ValueError) as raised: server.route_request( "/training/attempts", {}, dependencies=dependencies, method="POST", body=payload, ) self.assertEqual(str(raised.exception), expected_error) self.assertEqual(repository.calls, []) handler = _HandlerHarness( "/training/attempts", dependencies, method="POST", body=json.dumps(payload).encode("utf-8"), ) handler.dispatch() self._assert_public_error(handler, 400, expected_error) self.assertEqual(repository.calls, []) def test_missing_and_invalid_attempt_fields_return_public_400(self) -> None: post_cases = ( ("/training/attempts", {}, "missing_required_json_field:case_id"), ("/training/attempts", {"case_id": "not-a-number"}, "invalid_json_field:case_id"), ("/training/attempts", {"case_id": 7, "user_id": ""}, "invalid_json_field:user_id"), ("/training/attempts", {"case_id": 7, "current_frame_index": "bad"}, "invalid_json_field:current_frame_index"), ("/training/attempts", {"case_id": 7, "meta": []}, "invalid_json_field:meta"), ) for path, payload, expected_error in post_cases: with self.subTest(path=path, payload=payload): handler = _HandlerHarness( path, self.dependencies, method="POST", body=json.dumps(payload).encode("utf-8") ) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_missing_and_invalid_annotation_fields_return_public_400(self) -> None: post_cases = ( ("/training/attempts/not-an-id/annotations", {}, "invalid_path_param:attempt_id"), ("/training/attempts/101/annotations", {}, "missing_required_json_field:annotation_type"), ("/training/attempts/101/annotations", {"annotation_type": ""}, "invalid_json_field:annotation_type"), ( "/training/attempts/101/annotations", {"annotation_type": "zone"}, "missing_required_json_field:payload", ), ( "/training/attempts/101/annotations", {"annotation_type": "zone", "payload": []}, "invalid_json_field:payload", ), ( "/training/attempts/101/annotations", {"annotation_type": "zone", "payload": {}, "task_id": "bad"}, "invalid_json_field:task_id", ), ) for path, payload, expected_error in post_cases: with self.subTest(path=path, payload=payload): handler = _HandlerHarness( path, self.dependencies, method="POST", body=json.dumps(payload).encode("utf-8") ) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_invalid_submit_fields_return_public_400(self) -> None: post_cases = ( ("/training/attempts/not-an-id/submit", {}, "invalid_path_param:attempt_id"), ("/training/attempts/101/submit", {"total_score": "bad"}, "invalid_json_field:total_score"), ) for path, payload, expected_error in post_cases: with self.subTest(path=path, payload=payload): handler = _HandlerHarness( path, self.dependencies, method="POST", body=json.dumps(payload).encode("utf-8") ) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_missing_and_invalid_import_fields_return_public_400(self) -> None: post_cases = ( ({}, "missing_required_json_field:items"), ({"items": []}, "invalid_json_field:items"), ({"items": [{}]}, "missing_required_json_field:items[0].title"), ({"items": "bad"}, "invalid_json_field:items"), ) for payload, expected_error in post_cases: with self.subTest(payload=payload): handler = _HandlerHarness( "/training/cases/import", self.dependencies, method="POST", body=json.dumps(payload).encode("utf-8"), ) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": expected_error}) self.assertEqual(self.repository.calls, []) def test_malformed_or_non_object_bodies_return_public_400(self) -> None: for raw_body in (b"{", b"[]"): with self.subTest(raw_body=raw_body): handler = _HandlerHarness("/training/attempts", self.dependencies, method="POST", body=raw_body) handler.dispatch() self.assertEqual(handler.status, 400) self.assertEqual(handler.json_body(), {"error": "invalid_json_body"}) self.assertEqual(self.repository.calls, []) def test_error_responses_are_minimal_json_and_privacy_safe(self) -> None: raw_detail = "route failure marker " + "|".join(PRIVATE_DIAGNOSTIC_TERMS) failing_dependencies = dict(self.dependencies) failing_dependencies["training_cases"] = lambda **kwargs: (_ for _ in ()).throw(RuntimeError(raw_detail)) cases = ( ( "malformed request", "/training/attempts", "POST", b"{", self.dependencies, 400, "invalid_json_body", ), ( "validation error", "/training/attempts", "POST", json.dumps({"case_id": "not-a-number"}).encode("utf-8"), self.dependencies, 400, "invalid_json_field:case_id", ), ( "missing resource", "/training/cases/404", "GET", b"", self.dependencies, 404, "not_found", ), ( "masked failure", "/training/cases", "GET", b"", failing_dependencies, 500, "internal_server_error", ), ) for label, path, method, body, dependencies, expected_status, expected_error in cases: with self.subTest(label=label): handler = _HandlerHarness(path, dependencies, method=method, body=body) handler.dispatch() self._assert_public_error(handler, expected_status, expected_error) self.assertNotIn(raw_detail, json.dumps(handler.json_body(), sort_keys=True)) self.assertEqual(self.repository.calls, [("get_case", {"case_id": 404, INTERNAL_FIELD_FLAG: False})]) def test_documented_training_routes_expose_consistent_public_error_contract(self) -> None: route_cases = ( ("GET list", "/training/cases?limit=not-a-number", "GET", b"", 400, "invalid_limit_query_param:limit"), ("GET detail", "/training/cases/not-an-id", "GET", b"", 400, "invalid_path_param:case_id"), ( "GET export", "/training/cases/export?limit=not-a-number", "GET", b"", 400, "invalid_limit_query_param:limit", ), ("POST import", "/training/cases/import", "POST", b"[]", 400, "invalid_json_body"), ( "POST attempt", "/training/attempts", "POST", json.dumps({}).encode("utf-8"), 400, "missing_required_json_field:case_id", ), ( "POST annotation", "/training/attempts/not-an-id/annotations", "POST", json.dumps({}).encode("utf-8"), 400, "invalid_path_param:attempt_id", ), ( "POST submit", "/training/attempts/not-an-id/submit", "POST", json.dumps({}).encode("utf-8"), 400, "invalid_path_param:attempt_id", ), ("GET progress", "/training/progress?limit=not-a-number", "GET", b"", 400, "invalid_limit_query_param:limit"), ) for label, path, method, body, expected_status, expected_error in route_cases: with self.subTest(label=label): handler = _HandlerHarness(path, self.dependencies, method=method, body=body) handler.dispatch() self._assert_public_error(handler, expected_status, expected_error) self.assertEqual(self.repository.calls, []) def test_missing_case_returns_public_not_found(self) -> None: status, payload = server.route_request("/training/cases/404", {}, dependencies=self.dependencies) self.assertEqual(status, 404) self.assertEqual(payload, {"error": "not_found"}) self.assertEqual(self.repository.calls, [("get_case", {"case_id": 404, INTERNAL_FIELD_FLAG: False})]) def test_handler_internal_error_does_not_echo_raw_detail(self) -> None: marker = "raw failure marker" dependencies = dict(self.dependencies) dependencies["training_cases"] = lambda **kwargs: (_ for _ in ()).throw(RuntimeError(marker)) handler = _HandlerHarness("/training/cases", dependencies) handler.dispatch() serialized = json.dumps(handler.json_body(), sort_keys=True) self.assertEqual(handler.status, 500) self.assertEqual(handler.json_body(), {"error": "internal_server_error"}) self.assertNotIn(marker, serialized) if __name__ == "__main__": unittest.main()