567 lines
23 KiB
Python
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()
|