test_t3_unit.py python
272 lines 10.3 KB
Raw
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