test_t3_unit.py
python
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠ breaking
41 days ago
| 1 | """Unit tier tests — T3 dataset review and job queue state machine.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import unittest |
| 6 | |
| 7 | from scooling_lab_helpers import valid_payload |
| 8 | |
| 9 | from scooling_lab.contracts import TrainingJobStatus |
| 10 | from scooling_lab.dataset_review import ( |
| 11 | DatasetStatus, |
| 12 | DatasetStore, |
| 13 | RejectionReasonCode, |
| 14 | dataset_transition, |
| 15 | require_dataset_id, |
| 16 | validate_review_request, |
| 17 | ) |
| 18 | from scooling_lab.errors import ApiError, ErrorCode |
| 19 | from scooling_lab.service import TrainingApiService |
| 20 | from scooling_lab.store import TrainingJobStore |
| 21 | |
| 22 | |
| 23 | class T3UnitDatasetStateMachineTests(unittest.TestCase): |
| 24 | """Unit tests for the dataset review state machine.""" |
| 25 | |
| 26 | def test_unit_t3_registered_to_pending_review(self) -> None: |
| 27 | """registered → pending_review is a valid transition.""" |
| 28 | |
| 29 | self.assertEqual( |
| 30 | dataset_transition(DatasetStatus.REGISTERED, DatasetStatus.PENDING_REVIEW), |
| 31 | DatasetStatus.PENDING_REVIEW, |
| 32 | ) |
| 33 | |
| 34 | def test_unit_t3_pending_to_approved(self) -> None: |
| 35 | """pending_review → approved is a valid transition.""" |
| 36 | |
| 37 | self.assertEqual( |
| 38 | dataset_transition(DatasetStatus.PENDING_REVIEW, DatasetStatus.APPROVED), |
| 39 | DatasetStatus.APPROVED, |
| 40 | ) |
| 41 | |
| 42 | def test_unit_t3_pending_to_rejected(self) -> None: |
| 43 | """pending_review → rejected is a valid transition.""" |
| 44 | |
| 45 | self.assertEqual( |
| 46 | dataset_transition(DatasetStatus.PENDING_REVIEW, DatasetStatus.REJECTED), |
| 47 | DatasetStatus.REJECTED, |
| 48 | ) |
| 49 | |
| 50 | def test_unit_t3_approved_is_terminal(self) -> None: |
| 51 | """approved cannot transition to any other state.""" |
| 52 | |
| 53 | with self.assertRaises(ApiError) as raised: |
| 54 | dataset_transition(DatasetStatus.APPROVED, DatasetStatus.REJECTED) |
| 55 | self.assertEqual(raised.exception.code, ErrorCode.INVALID_TRANSITION) |
| 56 | |
| 57 | def test_unit_t3_rejected_is_terminal(self) -> None: |
| 58 | """rejected cannot transition to approved.""" |
| 59 | |
| 60 | with self.assertRaises(ApiError) as raised: |
| 61 | dataset_transition(DatasetStatus.REJECTED, DatasetStatus.APPROVED) |
| 62 | self.assertEqual(raised.exception.code, ErrorCode.INVALID_TRANSITION) |
| 63 | |
| 64 | def test_unit_t3_registered_cannot_skip_to_approved(self) -> None: |
| 65 | """registered → approved must go through pending_review.""" |
| 66 | |
| 67 | with self.assertRaises(ApiError) as raised: |
| 68 | dataset_transition(DatasetStatus.REGISTERED, DatasetStatus.APPROVED) |
| 69 | self.assertEqual(raised.exception.code, ErrorCode.INVALID_TRANSITION) |
| 70 | |
| 71 | def test_unit_t3_idempotent_same_state_replay(self) -> None: |
| 72 | """Same-state replay is allowed for every dataset status.""" |
| 73 | |
| 74 | for status in DatasetStatus: |
| 75 | with self.subTest(status=status): |
| 76 | self.assertEqual(dataset_transition(status, status), status) |
| 77 | |
| 78 | |
| 79 | class T3UnitRejectionReasonCodeTests(unittest.TestCase): |
| 80 | """Unit tests for the rejection reason code validation.""" |
| 81 | |
| 82 | def test_unit_t3_all_reason_codes_are_accepted(self) -> None: |
| 83 | """Every RejectionReasonCode value passes validation.""" |
| 84 | |
| 85 | for code in RejectionReasonCode: |
| 86 | with self.subTest(code=code): |
| 87 | from scooling_lab.dataset_review import require_rejection_reason |
| 88 | |
| 89 | self.assertEqual(require_rejection_reason(code.value), code) |
| 90 | |
| 91 | def test_unit_t3_free_text_reason_rejected(self) -> None: |
| 92 | """Arbitrary strings that are not enum members are rejected.""" |
| 93 | |
| 94 | from scooling_lab.dataset_review import require_rejection_reason |
| 95 | |
| 96 | with self.assertRaises(ApiError): |
| 97 | require_rejection_reason("because I said so") |
| 98 | |
| 99 | def test_unit_t3_url_shaped_reason_rejected(self) -> None: |
| 100 | """URL-shaped strings are not accepted as reason codes.""" |
| 101 | |
| 102 | from scooling_lab.dataset_review import require_rejection_reason |
| 103 | |
| 104 | with self.assertRaises(ApiError): |
| 105 | require_rejection_reason("https://attacker.invalid/reason") |
| 106 | |
| 107 | |
| 108 | class T3UnitReviewRequestValidationTests(unittest.TestCase): |
| 109 | """Unit tests for the review request payload validator.""" |
| 110 | |
| 111 | def test_unit_t3_approve_action_accepted(self) -> None: |
| 112 | """action=approve with no reasonCode is valid.""" |
| 113 | |
| 114 | action, reason = validate_review_request({"action": "approve"}) |
| 115 | self.assertEqual(action, "approve") |
| 116 | self.assertIsNone(reason) |
| 117 | |
| 118 | def test_unit_t3_reject_action_requires_reason_code(self) -> None: |
| 119 | """action=reject without a reasonCode raises VALIDATION_ERROR.""" |
| 120 | |
| 121 | with self.assertRaises(ApiError) as raised: |
| 122 | validate_review_request({"action": "reject"}) |
| 123 | self.assertEqual(raised.exception.code, ErrorCode.VALIDATION_ERROR) |
| 124 | |
| 125 | def test_unit_t3_reject_with_valid_reason_accepted(self) -> None: |
| 126 | """action=reject with a valid reasonCode returns both values.""" |
| 127 | |
| 128 | action, reason = validate_review_request( |
| 129 | {"action": "reject", "reasonCode": "POLICY_VIOLATION"} |
| 130 | ) |
| 131 | self.assertEqual(action, "reject") |
| 132 | self.assertEqual(reason, RejectionReasonCode.POLICY_VIOLATION) |
| 133 | |
| 134 | def test_unit_t3_unknown_action_rejected(self) -> None: |
| 135 | """Unknown action values fail validation.""" |
| 136 | |
| 137 | with self.assertRaises(ApiError): |
| 138 | validate_review_request({"action": "delete"}) |
| 139 | |
| 140 | def test_unit_t3_unknown_keys_rejected(self) -> None: |
| 141 | """Extra keys in the review payload are rejected.""" |
| 142 | |
| 143 | with self.assertRaises(ApiError): |
| 144 | validate_review_request( |
| 145 | {"action": "approve", "workerUrl": "http://attacker.invalid"} |
| 146 | ) |
| 147 | |
| 148 | def test_unit_t3_approve_with_reason_code_rejected(self) -> None: |
| 149 | """approve action must not carry a reasonCode.""" |
| 150 | |
| 151 | with self.assertRaises(ApiError): |
| 152 | validate_review_request( |
| 153 | {"action": "approve", "reasonCode": "FORMAT_INVALID"} |
| 154 | ) |
| 155 | |
| 156 | |
| 157 | class T3UnitDatasetIdValidationTests(unittest.TestCase): |
| 158 | """Unit tests for dataset id format enforcement.""" |
| 159 | |
| 160 | def test_unit_t3_safe_dataset_ids_accepted(self) -> None: |
| 161 | """Well-formed dataset ids within length bounds are accepted.""" |
| 162 | |
| 163 | safe_ids = ( |
| 164 | "fixture:synthetic-tiny-v1", |
| 165 | "ds.001", |
| 166 | "dataset-v2", |
| 167 | ) |
| 168 | for dataset_id in safe_ids: |
| 169 | with self.subTest(dataset_id=dataset_id): |
| 170 | self.assertEqual(require_dataset_id(dataset_id), dataset_id) |
| 171 | |
| 172 | def test_unit_t3_path_traversal_and_url_ids_rejected(self) -> None: |
| 173 | """Path-like and URL-like dataset ids are rejected.""" |
| 174 | |
| 175 | bad_ids = ( |
| 176 | "../private/dataset", |
| 177 | "https://attacker.invalid/ds", |
| 178 | "ds;rm-rf", |
| 179 | "d", # too short |
| 180 | ) |
| 181 | for bad in bad_ids: |
| 182 | with self.subTest(bad=bad): |
| 183 | with self.assertRaises(ApiError): |
| 184 | require_dataset_id(bad) |
| 185 | |
| 186 | |
| 187 | class T3UnitDatasetStoreTests(unittest.TestCase): |
| 188 | """Unit tests for the DatasetStore lifecycle operations.""" |
| 189 | |
| 190 | def test_unit_t3_fixture_dataset_pre_approved(self) -> None: |
| 191 | """The synthetic fixture dataset is pre-approved in a fresh store.""" |
| 192 | |
| 193 | store = DatasetStore() |
| 194 | self.assertTrue(store.is_approved("fixture:synthetic-tiny-v1")) |
| 195 | |
| 196 | def test_unit_t3_register_new_dataset(self) -> None: |
| 197 | """A new dataset starts in registered state.""" |
| 198 | |
| 199 | store = DatasetStore() |
| 200 | record = store.register("new-dataset-v1") |
| 201 | self.assertEqual(record.status, DatasetStatus.REGISTERED) |
| 202 | |
| 203 | def test_unit_t3_register_already_approved_raises_conflict(self) -> None: |
| 204 | """Re-registering an approved dataset raises CONFLICT.""" |
| 205 | |
| 206 | store = DatasetStore() |
| 207 | with self.assertRaises(ApiError) as raised: |
| 208 | store.register("fixture:synthetic-tiny-v1") |
| 209 | self.assertEqual(raised.exception.code, ErrorCode.CONFLICT) |
| 210 | |
| 211 | def test_unit_t3_full_approval_lifecycle(self) -> None: |
| 212 | """registered → pending_review → approved follows the state machine.""" |
| 213 | |
| 214 | store = DatasetStore() |
| 215 | store.register("lifecycle-ds-v1") |
| 216 | store.submit_for_review("lifecycle-ds-v1") |
| 217 | record = store.approve("lifecycle-ds-v1") |
| 218 | self.assertEqual(record.status, DatasetStatus.APPROVED) |
| 219 | self.assertTrue(store.is_approved("lifecycle-ds-v1")) |
| 220 | |
| 221 | def test_unit_t3_full_rejection_lifecycle(self) -> None: |
| 222 | """registered → pending_review → rejected carries the reason code.""" |
| 223 | |
| 224 | store = DatasetStore() |
| 225 | store.register("reject-ds-v1") |
| 226 | store.submit_for_review("reject-ds-v1") |
| 227 | record = store.reject("reject-ds-v1", RejectionReasonCode.FORMAT_INVALID) |
| 228 | self.assertEqual(record.status, DatasetStatus.REJECTED) |
| 229 | self.assertEqual(record.rejection_reason, RejectionReasonCode.FORMAT_INVALID) |
| 230 | self.assertFalse(store.is_approved("reject-ds-v1")) |
| 231 | |
| 232 | def test_unit_t3_unknown_dataset_is_not_found(self) -> None: |
| 233 | """Getting an unregistered dataset raises NOT_FOUND.""" |
| 234 | |
| 235 | store = DatasetStore() |
| 236 | with self.assertRaises(ApiError) as raised: |
| 237 | store.get("totally-unknown-ds") |
| 238 | self.assertEqual(raised.exception.code, ErrorCode.NOT_FOUND) |
| 239 | |
| 240 | def test_unit_t3_unapproved_dataset_blocks_job_creation(self) -> None: |
| 241 | """Jobs against a registered-but-not-yet-approved dataset are refused.""" |
| 242 | |
| 243 | store = DatasetStore() |
| 244 | store.register("new-dataset-v1") |
| 245 | service = TrainingApiService(TrainingJobStore(), dataset_store=store) |
| 246 | |
| 247 | bad_payload = { |
| 248 | "idempotencyKey": "test-unapproved", |
| 249 | "datasetId": "fixture:synthetic-tiny-v1", |
| 250 | "modelId": "fixture-tiny-llm", |
| 251 | "requestedBy": "unit-test", |
| 252 | } |
| 253 | with self.assertRaises(ApiError) as raised: |
| 254 | service.create_training_job({**bad_payload, "datasetId": "new-dataset-v1"}) |
| 255 | self.assertEqual(raised.exception.code, ErrorCode.DATASET_NOT_APPROVED) |
| 256 | |
| 257 | def test_unit_t3_queue_state_fields_present(self) -> None: |
| 258 | """get_queue_state returns all expected fields with correct types.""" |
| 259 | |
| 260 | service = TrainingApiService(TrainingJobStore()) |
| 261 | state = service.get_queue_state() |
| 262 | self.assertIn("queuedCount", state) |
| 263 | self.assertIn("runningCount", state) |
| 264 | self.assertIn("activeCount", state) |
| 265 | self.assertIn("maxConcurrentRunning", state) |
| 266 | self.assertIn("queueLimit", state) |
| 267 | self.assertIsInstance(state["queuedCount"], int) |
| 268 | self.assertIsInstance(state["runningCount"], int) |
| 269 | |
| 270 | |
| 271 | if __name__ == "__main__": |
| 272 | unittest.main() |
File History
1 commit
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠
41 days ago