Files
ai-exchange/tests/unit/test_training_api_error_contract_m9.py

567 lines
23 KiB
Python

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()