test_unit.py
python
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠ breaking
41 days ago
| 1 | """Unit tier tests for Scooling Lab T0/T2 contracts.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from datetime import UTC, datetime, timedelta |
| 6 | import unittest |
| 7 | |
| 8 | from scooling_lab_helpers import valid_payload |
| 9 | |
| 10 | from scooling_lab.contracts import TrainingJobRequest, TrainingJobStatus |
| 11 | from scooling_lab.dataset_review import ( |
| 12 | RejectionReasonCode, |
| 13 | dataset_shape_from_registration, |
| 14 | validate_synthetic_dataset_shape, |
| 15 | ) |
| 16 | from scooling_lab.errors import ApiError, ErrorCode, error_payload |
| 17 | from scooling_lab.license_policy import BomEntry, LicensePolicyError, validate_entry |
| 18 | from scooling_lab.provenance import ProvenanceRecord, validate_provenance_record |
| 19 | from scooling_lab.retention import ( |
| 20 | RetentionPolicyClass, |
| 21 | expires_at, |
| 22 | is_expired, |
| 23 | retention_policy_from_mapping, |
| 24 | ) |
| 25 | from scooling_lab.service import TrainingApiService |
| 26 | from scooling_lab.state_machine import cancel, transition |
| 27 | from scooling_lab.store import TrainingJobStore |
| 28 | |
| 29 | |
| 30 | class ScoolingLabUnitTests(unittest.TestCase): |
| 31 | """Unit tests for state transitions, schema, license policy, and errors.""" |
| 32 | |
| 33 | def test_unit_state_transitions_are_explicit_and_idempotent(self) -> None: |
| 34 | """queued -> running -> succeeded works and replay is a no-op.""" |
| 35 | |
| 36 | self.assertEqual( |
| 37 | transition(TrainingJobStatus.QUEUED, TrainingJobStatus.RUNNING), |
| 38 | TrainingJobStatus.RUNNING, |
| 39 | ) |
| 40 | self.assertEqual( |
| 41 | transition(TrainingJobStatus.RUNNING, TrainingJobStatus.SUCCEEDED), |
| 42 | TrainingJobStatus.SUCCEEDED, |
| 43 | ) |
| 44 | self.assertEqual( |
| 45 | transition(TrainingJobStatus.SUCCEEDED, TrainingJobStatus.SUCCEEDED), |
| 46 | TrainingJobStatus.SUCCEEDED, |
| 47 | ) |
| 48 | |
| 49 | def test_unit_invalid_state_transition_raises_safe_error(self) -> None: |
| 50 | """queued -> succeeded is not allowed by the transition table.""" |
| 51 | |
| 52 | with self.assertRaises(ApiError) as raised: |
| 53 | transition(TrainingJobStatus.QUEUED, TrainingJobStatus.SUCCEEDED) |
| 54 | self.assertEqual(raised.exception.code, ErrorCode.INVALID_TRANSITION) |
| 55 | |
| 56 | def test_unit_t4_cancel_transitions_and_terminal_noops(self) -> None: |
| 57 | """Cancellation changes active states and leaves terminal states unchanged.""" |
| 58 | |
| 59 | self.assertEqual(cancel(TrainingJobStatus.QUEUED), TrainingJobStatus.CANCELLED) |
| 60 | self.assertEqual(cancel(TrainingJobStatus.RUNNING), TrainingJobStatus.CANCELLED) |
| 61 | for terminal in ( |
| 62 | TrainingJobStatus.SUCCEEDED, |
| 63 | TrainingJobStatus.FAILED, |
| 64 | TrainingJobStatus.CANCELLED, |
| 65 | TrainingJobStatus.DELETED, |
| 66 | ): |
| 67 | with self.subTest(terminal=terminal): |
| 68 | self.assertEqual(cancel(terminal), terminal) |
| 69 | |
| 70 | def test_unit_t4_retry_state_transitions_refuse_invalid_originals(self) -> None: |
| 71 | """Only failed or cancelled jobs can produce a fresh retry job.""" |
| 72 | |
| 73 | service = TrainingApiService(TrainingJobStore(), auto_run_worker=False) |
| 74 | queued = service.create_training_job(valid_payload("retry-invalid-queued")) |
| 75 | with self.assertRaises(ApiError) as queued_retry: |
| 76 | service.retry_training_job(str(queued["id"])) |
| 77 | self.assertEqual(queued_retry.exception.code, ErrorCode.INVALID_TRANSITION) |
| 78 | |
| 79 | succeeded_service = TrainingApiService(TrainingJobStore()) |
| 80 | succeeded = succeeded_service.create_training_job(valid_payload("retry-invalid-done")) |
| 81 | with self.assertRaises(ApiError) as succeeded_retry: |
| 82 | succeeded_service.retry_training_job(str(succeeded["id"])) |
| 83 | self.assertEqual(succeeded_retry.exception.code, ErrorCode.INVALID_TRANSITION) |
| 84 | |
| 85 | def test_unit_t4_dataset_validation_maps_shapes_to_reason_codes(self) -> None: |
| 86 | """Synthetic dataset validation returns enum reasons for invalid shapes.""" |
| 87 | |
| 88 | _, valid_shape = dataset_shape_from_registration( |
| 89 | { |
| 90 | "datasetId": "unit-valid-shape", |
| 91 | "rowCount": 3, |
| 92 | "declaredSchema": { |
| 93 | "exampleId": "string", |
| 94 | "inputTokenCount": "integer", |
| 95 | "outputTokenCount": "integer", |
| 96 | "split": "string", |
| 97 | }, |
| 98 | } |
| 99 | ) |
| 100 | self.assertIsNone(validate_synthetic_dataset_shape(valid_shape)) |
| 101 | |
| 102 | _, too_large = dataset_shape_from_registration( |
| 103 | { |
| 104 | "datasetId": "unit-large-shape", |
| 105 | "rowCount": 10_001, |
| 106 | "declaredSchema": { |
| 107 | "exampleId": "string", |
| 108 | "inputTokenCount": "integer", |
| 109 | "outputTokenCount": "integer", |
| 110 | "split": "string", |
| 111 | }, |
| 112 | } |
| 113 | ) |
| 114 | self.assertEqual( |
| 115 | validate_synthetic_dataset_shape(too_large), |
| 116 | RejectionReasonCode.SYNTHETIC_LIMIT, |
| 117 | ) |
| 118 | |
| 119 | _, schema_mismatch = dataset_shape_from_registration( |
| 120 | { |
| 121 | "datasetId": "unit-schema-shape", |
| 122 | "rowCount": 3, |
| 123 | "declaredSchema": { |
| 124 | "exampleId": "string", |
| 125 | "inputTokenCount": "integer", |
| 126 | "split": "string", |
| 127 | }, |
| 128 | } |
| 129 | ) |
| 130 | self.assertEqual( |
| 131 | validate_synthetic_dataset_shape(schema_mismatch), |
| 132 | RejectionReasonCode.SCHEMA_MISMATCH, |
| 133 | ) |
| 134 | |
| 135 | def test_unit_deleted_state_transition_is_terminal(self) -> None: |
| 136 | """succeeded -> deleted works and deleted cannot transition elsewhere.""" |
| 137 | |
| 138 | self.assertEqual( |
| 139 | transition(TrainingJobStatus.SUCCEEDED, TrainingJobStatus.DELETED), |
| 140 | TrainingJobStatus.DELETED, |
| 141 | ) |
| 142 | self.assertEqual( |
| 143 | transition(TrainingJobStatus.DELETED, TrainingJobStatus.DELETED), |
| 144 | TrainingJobStatus.DELETED, |
| 145 | ) |
| 146 | with self.assertRaises(ApiError): |
| 147 | transition(TrainingJobStatus.DELETED, TrainingJobStatus.RUNNING) |
| 148 | |
| 149 | def test_unit_schema_rejects_unsafe_fields_and_unapproved_models(self) -> None: |
| 150 | """Dangerous keys and unknown model ids fail in schema validation.""" |
| 151 | |
| 152 | payload = valid_payload() |
| 153 | payload["workerUrl"] = "https://attacker.invalid/worker" |
| 154 | with self.assertRaises(ApiError): |
| 155 | TrainingJobRequest.from_mapping(payload) |
| 156 | |
| 157 | model_payload = valid_payload() |
| 158 | model_payload["modelId"] = "unapproved-model" |
| 159 | with self.assertRaises(ApiError): |
| 160 | TrainingJobRequest.from_mapping(model_payload) |
| 161 | |
| 162 | def test_unit_provenance_schema_rejects_free_text_paths_and_urls(self) -> None: |
| 163 | """Provenance accepts only exact hashes, ids, timestamps, and schema version.""" |
| 164 | |
| 165 | record = ProvenanceRecord( |
| 166 | artifact_hash="b" * 64, |
| 167 | base_model_id="fixture-tiny-llm", |
| 168 | created_at="2026-06-11T00:00:00Z", |
| 169 | dataset_hash="a" * 64, |
| 170 | job_id="job_" + "1" * 24, |
| 171 | training_config_hash="c" * 64, |
| 172 | ) |
| 173 | validate_provenance_record(record) |
| 174 | |
| 175 | unsafe_values = ( |
| 176 | "prompt body with words", |
| 177 | "../private/path", |
| 178 | "https://attacker.invalid/model", |
| 179 | ) |
| 180 | for unsafe in unsafe_values: |
| 181 | payload = record.to_dict() |
| 182 | payload["baseModelId"] = unsafe |
| 183 | with self.subTest(unsafe=unsafe): |
| 184 | with self.assertRaises(ApiError): |
| 185 | validate_provenance_record(payload) |
| 186 | |
| 187 | def test_unit_retention_ttl_math_is_bounded_and_deterministic(self) -> None: |
| 188 | """Retention policies validate TTL bounds and evaluate expiry exactly.""" |
| 189 | |
| 190 | policy = retention_policy_from_mapping( |
| 191 | {"policyClass": "ephemeral", "ttlSeconds": 60} |
| 192 | ) |
| 193 | self.assertEqual(policy.policy_class, RetentionPolicyClass.EPHEMERAL) |
| 194 | self.assertEqual(expires_at("2026-06-11T00:00:00Z", policy), "2026-06-11T00:01:00Z") |
| 195 | self.assertFalse( |
| 196 | is_expired( |
| 197 | "2026-06-11T00:00:00Z", |
| 198 | policy, |
| 199 | datetime(2026, 6, 11, 0, 0, 59, tzinfo=UTC), |
| 200 | ) |
| 201 | ) |
| 202 | self.assertTrue( |
| 203 | is_expired( |
| 204 | "2026-06-11T00:00:00Z", |
| 205 | policy, |
| 206 | datetime(2026, 6, 11, 0, 1, 0, tzinfo=UTC), |
| 207 | ) |
| 208 | ) |
| 209 | with self.assertRaises(ApiError): |
| 210 | retention_policy_from_mapping({"policyClass": "ephemeral", "ttlSeconds": 59}) |
| 211 | |
| 212 | def test_unit_deletion_state_transition_removes_content_fields(self) -> None: |
| 213 | """deleteArtifact leaves a content-free deleted job tombstone.""" |
| 214 | |
| 215 | service = TrainingApiService(TrainingJobStore()) |
| 216 | created = service.create_training_job(valid_payload("delete-unit")) |
| 217 | job_id = str(created["id"]) |
| 218 | artifact_id = str(service.list_artifacts(job_id)["artifacts"][0]["id"]) |
| 219 | |
| 220 | first_delete = service.delete_artifact(job_id, artifact_id) |
| 221 | second_delete = service.delete_artifact(job_id, artifact_id) |
| 222 | tombstone = service.get_training_job(job_id) |
| 223 | |
| 224 | self.assertTrue(first_delete["deleted"]) |
| 225 | self.assertTrue(first_delete["verified"]) |
| 226 | self.assertTrue(second_delete["alreadyDeleted"]) |
| 227 | self.assertEqual(tombstone["status"], "deleted") |
| 228 | self.assertNotIn("request", tombstone) |
| 229 | self.assertNotIn("artifactHash", str(tombstone)) |
| 230 | |
| 231 | def test_unit_license_policy_blocks_agpl_and_blocked_paths(self) -> None: |
| 232 | """The BOM policy rejects AGPL licenses and Unsloth Studio/CLI paths.""" |
| 233 | |
| 234 | validate_entry( |
| 235 | BomEntry( |
| 236 | name="safe", |
| 237 | version="1.0.0", |
| 238 | license="MIT", |
| 239 | source_path="third_party/safe", |
| 240 | evidence="fixture", |
| 241 | ) |
| 242 | ) |
| 243 | with self.assertRaises(LicensePolicyError): |
| 244 | validate_entry( |
| 245 | BomEntry( |
| 246 | name="blocked", |
| 247 | version="1.0.0", |
| 248 | license="AGPL-3.0", |
| 249 | source_path="third_party/blocked", |
| 250 | evidence="fixture", |
| 251 | ) |
| 252 | ) |
| 253 | with self.assertRaises(LicensePolicyError): |
| 254 | validate_entry( |
| 255 | BomEntry( |
| 256 | name="blocked-path", |
| 257 | version="1.0.0", |
| 258 | license="Apache-2.0", |
| 259 | source_path="vendor/unsloth_cli/main.py", |
| 260 | evidence="fixture", |
| 261 | ) |
| 262 | ) |
| 263 | |
| 264 | def test_unit_error_model_does_not_echo_payload_or_paths(self) -> None: |
| 265 | """Safe errors expose only code and stable public message.""" |
| 266 | |
| 267 | payload = error_payload(ApiError(ErrorCode.VALIDATION_ERROR, 400)) |
| 268 | text = str(payload) |
| 269 | self.assertIn("VALIDATION_ERROR", text) |
| 270 | self.assertNotIn("/Users/", text) |
| 271 | self.assertNotIn("attacker.invalid", text) |
| 272 | self.assertNotIn("payload", text.lower()) |
| 273 | |
| 274 | |
| 275 | if __name__ == "__main__": |
| 276 | unittest.main() |
File History
1 commit
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠
41 days ago