test_t3_integration.py
python
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠ breaking
41 days ago
| 1 | """Integration tier tests — T3 dataset review + job lifecycle API contract.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import unittest |
| 6 | from datetime import UTC, datetime, timedelta |
| 7 | |
| 8 | from scooling_lab_helpers import valid_payload |
| 9 | |
| 10 | from scooling_lab.dataset_review import ( |
| 11 | DatasetStatus, |
| 12 | DatasetStore, |
| 13 | RejectionReasonCode, |
| 14 | default_dataset_shape, |
| 15 | ) |
| 16 | from scooling_lab.errors import ApiError, ErrorCode |
| 17 | from scooling_lab.provenance import validate_provenance_record |
| 18 | from scooling_lab.service import TrainingApiService |
| 19 | from scooling_lab.store import TrainingJobStore |
| 20 | |
| 21 | |
| 22 | def _make_service( |
| 23 | *, |
| 24 | queue_limit: int = 10, |
| 25 | max_concurrent_running: int = 1, |
| 26 | dataset_store: DatasetStore | None = None, |
| 27 | auto_run: bool = True, |
| 28 | ) -> TrainingApiService: |
| 29 | """Convenience factory for integration tests.""" |
| 30 | |
| 31 | store = TrainingJobStore( |
| 32 | queue_limit=queue_limit, max_concurrent_running=max_concurrent_running |
| 33 | ) |
| 34 | return TrainingApiService( |
| 35 | store, auto_run_worker=auto_run, dataset_store=dataset_store |
| 36 | ) |
| 37 | |
| 38 | |
| 39 | class T3IntegrationDatasetReviewLifecycleTests(unittest.TestCase): |
| 40 | """Integration tests for the full dataset registration → review flow.""" |
| 41 | |
| 42 | def test_integration_t3_register_review_approve_then_submit_job(self) -> None: |
| 43 | """A dataset that goes through the full approval flow allows job creation.""" |
| 44 | |
| 45 | ds_store = DatasetStore() |
| 46 | ds_store.register("custom-ds-v1") |
| 47 | ds_store.submit_for_review("custom-ds-v1") |
| 48 | ds_store.approve("custom-ds-v1") |
| 49 | # Use a service that also approves the fixture dataset (default). |
| 50 | service = TrainingApiService(TrainingJobStore(), dataset_store=ds_store) |
| 51 | |
| 52 | # Standard fixture job still works because fixture is pre-approved. |
| 53 | created = service.create_training_job(valid_payload("integration-approved")) |
| 54 | self.assertEqual(created["status"], "succeeded") |
| 55 | |
| 56 | def test_integration_t3_unapproved_dataset_blocks_job_at_api_boundary(self) -> None: |
| 57 | """A dataset that is only registered (not approved) blocks job submission.""" |
| 58 | |
| 59 | ds_store = DatasetStore() |
| 60 | ds_store.register("custom-ds-v2") |
| 61 | service = TrainingApiService(TrainingJobStore(), dataset_store=ds_store) |
| 62 | with self.assertRaises(ApiError) as raised: |
| 63 | service.create_training_job( |
| 64 | { |
| 65 | "idempotencyKey": "integration-blocked", |
| 66 | "datasetId": "custom-ds-v2", |
| 67 | "modelId": "fixture-tiny-llm", |
| 68 | "requestedBy": "integration-test", |
| 69 | } |
| 70 | ) |
| 71 | self.assertEqual(raised.exception.code, ErrorCode.DATASET_NOT_APPROVED) |
| 72 | |
| 73 | def test_integration_t3_rejected_dataset_blocks_job(self) -> None: |
| 74 | """A rejected dataset is refused at job creation with DATASET_NOT_APPROVED.""" |
| 75 | |
| 76 | ds_store = DatasetStore() |
| 77 | ds_store.register_shape( |
| 78 | "custom-ds-v3", |
| 79 | default_dataset_shape(RejectionReasonCode.POLICY_VIOLATION), |
| 80 | ) |
| 81 | ds_store.submit_for_review("custom-ds-v3") |
| 82 | ds_store.reject("custom-ds-v3", RejectionReasonCode.POLICY_VIOLATION) |
| 83 | service = TrainingApiService(TrainingJobStore(), dataset_store=ds_store) |
| 84 | with self.assertRaises(ApiError) as raised: |
| 85 | service.create_training_job( |
| 86 | { |
| 87 | "idempotencyKey": "integration-rejected", |
| 88 | "datasetId": "custom-ds-v3", |
| 89 | "modelId": "fixture-tiny-llm", |
| 90 | "requestedBy": "integration-test", |
| 91 | } |
| 92 | ) |
| 93 | self.assertEqual(raised.exception.code, ErrorCode.DATASET_NOT_APPROVED) |
| 94 | |
| 95 | def test_integration_t3_rejection_reason_in_dataset_record(self) -> None: |
| 96 | """Rejection reason code is visible in the dataset record's public dict.""" |
| 97 | |
| 98 | ds_store = DatasetStore() |
| 99 | ds_store.register_shape( |
| 100 | "reason-ds-v1", |
| 101 | default_dataset_shape(RejectionReasonCode.SCHEMA_MISMATCH), |
| 102 | ) |
| 103 | ds_store.submit_for_review("reason-ds-v1") |
| 104 | ds_store.reject("reason-ds-v1", RejectionReasonCode.SCHEMA_MISMATCH) |
| 105 | record = ds_store.get("reason-ds-v1") |
| 106 | public = record.to_public_dict() |
| 107 | self.assertEqual(public["rejectionReasonCode"], "SCHEMA_MISMATCH") |
| 108 | self.assertEqual(public["status"], DatasetStatus.REJECTED.value) |
| 109 | |
| 110 | def test_integration_t3_dataset_status_does_not_expose_free_text(self) -> None: |
| 111 | """Public dataset record contains no free-text fields from request payload.""" |
| 112 | |
| 113 | ds_store = DatasetStore() |
| 114 | ds_store.register_shape( |
| 115 | "clean-ds-v1", |
| 116 | default_dataset_shape(RejectionReasonCode.FORMAT_INVALID), |
| 117 | ) |
| 118 | ds_store.submit_for_review("clean-ds-v1") |
| 119 | ds_store.reject("clean-ds-v1", RejectionReasonCode.FORMAT_INVALID) |
| 120 | public_str = str(ds_store.get("clean-ds-v1").to_public_dict()) |
| 121 | self.assertNotIn("caller message", public_str) |
| 122 | self.assertIn("FORMAT_INVALID", public_str) |
| 123 | |
| 124 | |
| 125 | class T3IntegrationQueueStateTests(unittest.TestCase): |
| 126 | """Integration tests for queue state inspection.""" |
| 127 | |
| 128 | def test_integration_t3_queue_state_reflects_active_jobs(self) -> None: |
| 129 | """Queue state accurately counts queued/running/active jobs.""" |
| 130 | |
| 131 | service = _make_service(auto_run=False, max_concurrent_running=2, queue_limit=20) |
| 132 | initial = service.get_queue_state() |
| 133 | self.assertEqual(initial["queuedCount"], 0) |
| 134 | self.assertEqual(initial["runningCount"], 0) |
| 135 | self.assertEqual(initial["activeCount"], 0) |
| 136 | |
| 137 | service.create_training_job(valid_payload("queue-state-a")) |
| 138 | service.create_training_job(valid_payload("queue-state-b")) |
| 139 | after = service.get_queue_state() |
| 140 | self.assertEqual(after["queuedCount"], 2) |
| 141 | self.assertEqual(after["maxConcurrentRunning"], 2) |
| 142 | |
| 143 | def test_integration_t3_queue_state_after_completion(self) -> None: |
| 144 | """Completed jobs do not count in the active queue.""" |
| 145 | |
| 146 | service = _make_service(auto_run=True, max_concurrent_running=5, queue_limit=20) |
| 147 | service.create_training_job(valid_payload("complete-queue")) |
| 148 | after = service.get_queue_state() |
| 149 | self.assertEqual(after["queuedCount"], 0) |
| 150 | self.assertEqual(after["runningCount"], 0) |
| 151 | self.assertEqual(after["activeCount"], 0) |
| 152 | |
| 153 | |
| 154 | class T3IntegrationProvenanceOnCompletionTests(unittest.TestCase): |
| 155 | """Integration tests confirming provenance is always emitted via the Slice-5 validator.""" |
| 156 | |
| 157 | def test_integration_t3_every_succeeded_job_has_valid_provenance(self) -> None: |
| 158 | """Succeeded jobs always expose a schema-valid provenance record.""" |
| 159 | |
| 160 | service = _make_service() |
| 161 | for suffix in ("prov-a", "prov-b", "prov-c"): |
| 162 | created = service.create_training_job(valid_payload(suffix)) |
| 163 | job_id = str(created["id"]) |
| 164 | self.assertEqual(created["status"], "succeeded") |
| 165 | prov = service.get_provenance(job_id) |
| 166 | validate_provenance_record(prov) |
| 167 | self.assertEqual(prov["jobId"], job_id) |
| 168 | |
| 169 | def test_integration_t3_provenance_links_to_artifact(self) -> None: |
| 170 | """Provenance artifact hash matches the artifact in the job's artifact list.""" |
| 171 | |
| 172 | service = _make_service() |
| 173 | created = service.create_training_job(valid_payload("prov-link")) |
| 174 | job_id = str(created["id"]) |
| 175 | artifact = service.list_artifacts(job_id)["artifacts"][0] |
| 176 | prov = service.get_provenance(job_id) |
| 177 | self.assertEqual(prov["artifactHash"], artifact["artifactHash"]) |
| 178 | |
| 179 | |
| 180 | class T3IntegrationRetentionIntegrationTests(unittest.TestCase): |
| 181 | """Integration tests for retention integration (Slice 5 + T3).""" |
| 182 | |
| 183 | def test_integration_t3_expired_artifact_tombstone_has_provenance(self) -> None: |
| 184 | """After TTL expiry the tombstone still exposes its provenance record.""" |
| 185 | |
| 186 | service = _make_service() |
| 187 | policy = {"policyClass": "ephemeral", "ttlSeconds": 60} |
| 188 | created = service.create_training_job(valid_payload("retention-prov", policy)) |
| 189 | job_id = str(created["id"]) |
| 190 | prov_before = service.get_provenance(job_id) |
| 191 | |
| 192 | service.sweep_expired_artifacts(datetime.now(UTC) + timedelta(seconds=120)) |
| 193 | |
| 194 | tombstone = service.get_training_job(job_id) |
| 195 | self.assertEqual(tombstone["status"], "deleted") |
| 196 | self.assertEqual(service.list_artifacts(job_id)["artifacts"], []) |
| 197 | prov_after = service.get_provenance(job_id) |
| 198 | self.assertEqual(prov_before["jobId"], prov_after["jobId"]) |
| 199 | self.assertEqual(prov_before["artifactHash"], prov_after["artifactHash"]) |
| 200 | validate_provenance_record(prov_after) |
| 201 | |
| 202 | def test_integration_t3_explicit_delete_wipes_provenance(self) -> None: |
| 203 | """Explicit deleteArtifact wipes provenance (Slice-5 contract preserved).""" |
| 204 | |
| 205 | service = _make_service() |
| 206 | created = service.create_training_job(valid_payload("explicit-delete")) |
| 207 | job_id = str(created["id"]) |
| 208 | artifact_id = str(service.list_artifacts(job_id)["artifacts"][0]["id"]) |
| 209 | service.delete_artifact(job_id, artifact_id) |
| 210 | with self.assertRaises(ApiError) as raised: |
| 211 | service.get_provenance(job_id) |
| 212 | self.assertEqual(raised.exception.code, ErrorCode.NOT_FOUND) |
| 213 | |
| 214 | |
| 215 | if __name__ == "__main__": |
| 216 | unittest.main() |
File History
1 commit
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠
41 days ago