264 lines
10 KiB
Python
264 lines
10 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"
|
|
|
|
|
|
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))
|
|
|
|
|
|
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: [],
|
|
}
|
|
|
|
|
|
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 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_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_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_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()
|