From 82217a7eb8771c0da9c1d681f59780d78320dc19 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 30 Apr 2026 22:12:50 -0400 Subject: [PATCH] test: lock training API privacy contract --- .../test_training_public_api_contract_m8.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/unit/test_training_public_api_contract_m8.py b/tests/unit/test_training_public_api_contract_m8.py index eecf4ff..9b4ed0c 100644 --- a/tests/unit/test_training_public_api_contract_m8.py +++ b/tests/unit/test_training_public_api_contract_m8.py @@ -14,6 +14,12 @@ TRAINING_PRODUCT_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readm INTERNAL_FIELD = "answer" + "_key" PRIVATE_MARKER = "trainer-only-choice" PRIVATE_RUBRIC_MARKER = "trainer-only-rubric" +NESTED_PRIVATE_MARKERS = ( + "m17-private-choice", + "m17-private-close", + "m17-private-rubric", +) +PRIVATE_FIELD_TEXT = "public text mentions " + INTERNAL_FIELD DOCUMENTED_TRAINING_ROUTES = ( ("GET", "/training/cases"), @@ -129,6 +135,70 @@ class _PublicTrainingRepository: } +class _NestedPublicTrainingRepository: + def __init__(self) -> None: + self.get_case_args = None + + def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100): + return [self._case_row(17)][:limit] + + 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 _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-21T09:00:00Z", + "decision_ts": "2026-04-21T10:00:00Z", + "end_ts": "2026-04-21T11:00:00Z", + "difficulty": "easy", + "status": "published", + "source_event_refs": [{"kind": "sweep_event", "id": 17}], + INTERNAL_FIELD: { + "answer": {"side": NESTED_PRIVATE_MARKERS[0], "close_back_inside": NESTED_PRIVATE_MARKERS[1]}, + "rubric": [{"category": NESTED_PRIVATE_MARKERS[2], "max_score": 5}], + }, + "meta": { + "title": "Nested public route privacy case", + "skill_tags": ["sweep", "context_reading"], + "public_note": PRIVATE_FIELD_TEXT, + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "sweep", + "prompt": "Describe the public confirmation.", + "required_annotation_types": ["text"], + "max_score": 5, + "meta": { + "public_hint": "Focus on context.", + "public_note": PRIVATE_FIELD_TEXT, + "nested": { + "answer": NESTED_PRIVATE_MARKERS[1], + INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[2], + }, + }, + } + ], + "rubric": [ + { + "category": "context", + "max_score": 3, + "answer": NESTED_PRIVATE_MARKERS[0], + INTERNAL_FIELD: {"rubric": NESTED_PRIVATE_MARKERS[2]}, + } + ], + }, + } + + def _documented_routes() -> tuple: text = TRAINING_PRODUCT_DOC.read_text(encoding="utf-8") routes = [] @@ -184,6 +254,21 @@ def _public_import_package() -> dict: } +def _dict_keys(value) -> list[str]: + if isinstance(value, dict): + keys = [] + for key, item in value.items(): + keys.append(str(key)) + keys.extend(_dict_keys(item)) + return keys + if isinstance(value, list): + keys = [] + for item in value: + keys.extend(_dict_keys(item)) + return keys + return [] + + class TrainingPublicApiContractM8Tests(unittest.TestCase): def test_documented_training_routes_resolve_to_expected_handlers(self) -> None: self.assertEqual(_documented_routes(), DOCUMENTED_TRAINING_ROUTES) @@ -320,6 +405,31 @@ class TrainingPublicApiContractM8Tests(unittest.TestCase): with self.subTest(private_text=private_text): self.assertNotIn(private_text, serialized) + def test_public_training_list_detail_routes_scrub_nested_solution_material(self) -> None: + repository = _NestedPublicTrainingRepository() + 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/17", {}, dependencies=dependencies) + + self.assertEqual([list_status, detail_status], [200, 200]) + self.assertEqual(repository.get_case_args, {"case_id": 17, "include_" + INTERNAL_FIELD: False}) + self.assertEqual(list_payload["items"][0]["meta"]["tasks"][0]["meta"]["public_hint"], "Focus on context.") + self.assertEqual(detail_payload["meta"]["rubric"], [{"category": "context", "max_score": 3}]) + + payload = {"list": list_payload, "detail": detail_payload} + serialized = json.dumps(payload, sort_keys=True) + + self.assertNotIn(INTERNAL_FIELD, serialized) + for private_text in NESTED_PRIVATE_MARKERS: + with self.subTest(private_text=private_text): + self.assertNotIn(private_text, serialized) + + keys = _dict_keys(payload) + self.assertNotIn("answer", keys) + self.assertNotIn(INTERNAL_FIELD, keys) + if __name__ == "__main__": unittest.main()