# Blackbox Tests ## Positive Scenarios ### FT-P-01: Annotation create — single detection, small image **Summary**: A `POST /annotations` with a small frame and one synthetic detection persists the row, writes the YOLO label file, and returns the persisted DTO. **Traces to**: AC-F-01, AC-F-03, AC-F-04 **Category**: Annotation lifecycle — Create **Preconditions**: - SUT healthy (`/health` returns 200) - DB clean (no rows in `annotations`, `detection`, `media`, `annotations_queue_records`) - Runner has minted an ES256 token with the `ANN` claim (see `test-data.md` → "Bearer token harness") **Input data**: `image_small.jpg` + `F1_001_request.json` (1 detection, `class_num=10` Plane, normalized bbox) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with the request body | HTTP 200; body matches `AnnotationDto` schema; `body.id =~ /^[0-9a-f]{32}$/`; `body.detections.length == 1` | | 2 | Out-of-band: assert `/.jpg` exists with same bytes as `image_small.jpg` | file present, byte-for-byte match | | 3 | Out-of-band: assert `/.txt` exists with one line `10 0.45 0.32 0.08 0.12` (or whatever the request supplied, formatted) | file present, line matches regex `^10 \d+\.\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+$` | | 4 | `GET /annotations/{id}` | HTTP 200; same body as step 1 | **Expected outcome**: the persisted entity round-trips through `GET /annotations/{id}` byte-for-byte, the image file is on disk, and the label file format is YOLO. **Max execution time**: 5s --- ### FT-P-02: Annotation create — idempotency on identical re-POST **Summary**: Re-POSTing the same image bytes + same detections does not create a new row; the second response carries the same `id`. **Traces to**: AC-F-01, AC-F-02 **Preconditions**: - FT-P-01 has just succeeded, so an annotation for `image_small.jpg` already exists. **Input data**: same as FT-P-01 **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with the same body | HTTP 200; `body.id == ` | | 2 | Out-of-band: count rows in `annotations WHERE id = ` | `count == 1` | **Expected outcome**: idempotent write — same hash → same id → same row. **Max execution time**: 5s --- ### FT-P-03: Annotation create — empty scene, 0 detections **Summary**: An empty-scene image with 0 detections creates an annotation row with no detection rows; the YOLO label file is empty. **Traces to**: AC-F-03 (label-file format with 0 detections) **Preconditions**: clean state. **Input data**: `image_empty_scene.jpg` + `F1_003_request.json` (0 detections) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` | HTTP 200; `body.detections.length == 0` | | 2 | Out-of-band: read `/.txt` | file exists; content is empty (0 bytes) or whitespace-only | | 3 | Out-of-band: count rows in `detection WHERE annotation_id = ` | `count == 0` | **Expected outcome**: persisted annotation with empty detections; label file present and empty. **Max execution time**: 5s --- ### FT-P-04: Annotation create — dense scene, 5 mixed-class detections **Summary**: A dense frame with 5 detections across multiple seeded classes persists 5 detection rows, writes a 5-line YOLO label, and returns a DTO with all 5 detections. **Traces to**: AC-F-03, AC-F-04 **Preconditions**: clean state. **Input data**: `image_dense01.jpg` + `F1_004_request.json` (5 detections, class_num ∈ {0=ArmorVehicle, 1=Truck, 2=Vehicle, 9=Smoke, 10=Plane}) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` | HTTP 200; `body.detections.length == 5` | | 2 | Out-of-band: read `/.txt` | exactly 5 lines; each matches `^\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+ \d+\.\d+$` | | 3 | Compare line set against expected | line set equals the 5 detections in `F1_004_request.json` (order may differ — test uses set equality) | **Expected outcome**: 5 detections round-trip through both DB and YOLO label. **Max execution time**: 5s --- ### FT-P-05: Annotation listing — paginated read **Summary**: After several creates, `GET /annotations` returns a paginated list with the correct shape and count. **Traces to**: AC-F-04 (read path) **Preconditions**: FT-P-01..FT-P-04 have run; 4 annotations exist. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations?limit=10` | HTTP 200; `body.length == 4`; each item conforms to `AnnotationListItem` schema | | 2 | `GET /annotations?limit=2&offset=0` | HTTP 200; `body.length == 2` | | 3 | `GET /annotations?limit=2&offset=2` | HTTP 200; `body.length == 2`; ids disjoint from step 2's response | **Expected outcome**: paginated read works; results are stable across paging windows. **Max execution time**: 5s --- ### FT-P-06: Annotation detail by id **Summary**: `GET /annotations/{id}` returns the full DTO including detections. **Traces to**: AC-F-04 **Preconditions**: FT-P-04 has run. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations/` | HTTP 200; body matches `AnnotationDto`; `body.detections.length == 5` | **Max execution time**: 3s --- ### FT-P-07: SSE delivery — event for new annotation **Summary**: A subscriber connected to `/annotations/events?missionId=` receives the lifecycle event for a `POST /annotations` against that mission within 1 second. **Traces to**: AC-F-10 **Preconditions**: clean state. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open SSE connection to `/annotations/events?missionId=` | HTTP 200; `Content-Type: text/event-stream` | | 2 | `POST /annotations` against mission `` | HTTP 200 | | 3 | Read next event from the SSE stream | event arrives within 1000ms; `event.data` parses as `AnnotationEventDto`; `event.operation == "Created"`; `event.annotationId == ` | **Expected outcome**: real-time delivery of the lifecycle event. **Max execution time**: 10s --- ### FT-P-08: Outbox row on create **Summary**: A successful `POST /annotations` inserts exactly one row into `annotations_queue_records` with `operation == 10` (Created). **Traces to**: AC-F-12 (outbox drain), AC-F-05 (`[after RB-01]` for non-Created paths) **Preconditions**: clean state; RabbitMQ broker reachable but the test does not consume from the stream yet. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` (any valid payload) | HTTP 200 | | 2 | Out-of-band: `SELECT COUNT(*) FROM annotations_queue_records WHERE annotation_id = AND operation = 10` immediately after step 1 | `count == 1` (within 500ms — outbox insert happens before the response returns) | **Max execution time**: 5s --- ### FT-P-09: Stream message round-trip **Summary**: After the outbox drain interval, a message arrives on the `azaion-annotations` stream that decodes to the documented schema. **Traces to**: AC-F-12 **Preconditions**: FT-P-08 just succeeded; outbox row present. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Connect a stream consumer to `azaion-annotations` at offset `next` | consumer alive | | 2 | Wait up to `drain_interval + 2s` for one message | one message arrives | | 3 | gzip-decompress + MessagePack-deserialize the body | object matches the documented stream schema | | 4 | Out-of-band: re-query `annotations_queue_records WHERE annotation_id = ` | `count == 0` (drainer deleted the row) | **Max execution time**: 30s (depends on configured drain interval) --- ### FT-P-10: Media single upload **Summary**: `POST /media` (multipart) persists the file and a media row. **Traces to**: AC-F-20 **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /media` with `image_small.jpg`, `mediaType=Image`, `waypointId=` (multipart) | HTTP 200; body matches `MediaListItem` schema | | 2 | Out-of-band: `/.jpg` exists | file present | **Max execution time**: 5s --- ### FT-P-11: Media batch upload **Summary**: `POST /media/batch` with N files persists N rows + N files in one request. **Traces to**: AC-F-21 **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /media/batch` with 3 distinct files (`image_small`, `image_dense01`, `image_dense02`) | HTTP 200; `body.length == 3`; 3 distinct media ids | | 2 | Out-of-band: 3 distinct files exist on disk | 3 files present | **Max execution time**: 10s --- ### FT-P-12: Bearer token verification — happy path **Summary**: A request bearing an ES256 access token whose `iss`, `aud`, signature, and `exp` are all valid is accepted by every authenticated endpoint reached. **Traces to**: AC-F-50 **Preconditions**: A test-only ES256 key pair is published at the `JWT_JWKS_URL` fetched by the service at boot (see `test-data.md` → "Bearer token harness"). The runner mints an access token signed with the matching private key. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations` with `Authorization: Bearer ` | HTTP 200; valid `PaginatedResponse` body | **Max execution time**: 3s --- ### FT-P-13: Bearer token verification — alg pinning **Summary**: A token signed with `alg=HS256` (using the public ES256 key as the HMAC secret) is rejected — `JwtExtensions.AddJwtAuth` pins `ValidAlgorithms = [EcdsaSha256]`. **Traces to**: AC-F-50 **Preconditions**: Same harness as FT-P-12. Runner additionally produces a forged HS256 token using the public ES256 key bytes as the HMAC key. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations` with `Authorization: Bearer ` | HTTP 401; error envelope | **Max execution time**: 3s --- ### FT-P-14: Detection class catalog read **Summary**: `GET /classes` returns the 19 seeded classes with stable ids. **Traces to**: AC-F-41 **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /classes` | HTTP 200; `body.length == 19`; ids `[0..18]` present (set equality); entry where `id==9` has `name=="Smoke"`; entry where `id==10` has `name=="Plane"` | **Max execution time**: 3s --- ### FT-P-15: Directory settings → PathResolver invariant **Summary**: `PUT /settings/directories` updates the values; the next annotation create writes to the new path. **Traces to**: AC-F-40 **Preconditions**: ADM JWT in hand. Volume mounts include both old and new paths so the SUT can write to either. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /settings/directories` | HTTP 200; record current `imagesDir` | | 2 | `PUT /settings/directories` with `imagesDir = /data/images-alt` | HTTP 200 | | 3 | `GET /settings/directories` | HTTP 200; `imagesDir == "/data/images-alt"` | | 4 | `POST /annotations` for a fresh image | HTTP 200; out-of-band: image lands at `/data/images-alt/.jpg`, NOT at the original `imagesDir` | **Expected outcome**: `pathResolver.Reset()` has fired and the next write uses the new directory. **Max execution time**: 10s --- ### FT-P-16: Dataset filter by status **Summary**: `GET /dataset?status=10` (Pending) returns only Pending rows. **Traces to**: AC-F-30 **Preconditions**: FT-P-04 just ran; one Pending annotation exists. **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /dataset?status=10` | HTTP 200; every item in `body` has `status == 10` | **Max execution time**: 3s --- ### FT-P-17: Dataset class distribution **Summary**: `GET /dataset/class-distribution` returns counts grouped by class with the expected shape. **Traces to**: AC-F-30 (read path), AC-F-41 (class metadata) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /dataset/class-distribution` after FT-P-04 (5 detections of mixed classes) | HTTP 200; body is an array; entry for `classNum=10` has `count >= 1`; sum of all `count` values equals total detection rows | **Max execution time**: 3s --- ### FT-P-18: Dataset bulk status **Summary**: `POST /dataset/status/bulk` flips status atomically on N rows. **Traces to**: AC-F-31 **Preconditions**: 2+ Pending annotations from FT-P-01 and FT-P-04 (now FT-P-04 has 1; need at least 2). **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /dataset/status/bulk` with `{annotationIds: [, ], status: 20}` | HTTP 200 | | 2 | `GET /annotations/` | HTTP 200; `body.status == 20` | | 3 | `GET /annotations/` | HTTP 200; `body.status == 20` | **Max execution time**: 5s --- ### FT-P-19: Health check **Summary**: `GET /health` returns 200 with low latency at any time post-boot. **Traces to**: AC-F-54 **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /health` | HTTP 200 within 200ms | **Max execution time**: 2s --- ### FT-P-20: Migrator idempotence **Summary**: Restarting the SUT against the same DB makes 0 schema changes. **Traces to**: AC-N-02 **Preconditions**: SUT booted once; DB schema captured (e.g., `pg_dump --schema-only`). **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Capture schema-only dump → `dump_a.sql` | non-empty dump | | 2 | `docker compose restart annotations` | SUT comes back healthy | | 3 | Capture schema-only dump → `dump_b.sql` | non-empty dump | | 4 | Diff `dump_a.sql` and `dump_b.sql` | zero meaningful differences (whitespace / SERIAL counters tolerated) | **Max execution time**: 30s --- ### FT-P-21 `[after RB-01]`: Lifecycle event on update **Summary**: `PUT /annotations/{id}` emits an `Updated` SSE event AND inserts an outbox row. **Traces to**: AC-F-05 (post RB-01) **Note**: this test stays disabled (skipped with reason `"awaiting RB-01"`) until the refactor lands. --- ### FT-P-22 `[after RB-01]`: Lifecycle event on delete + soft-delete file relocation **Summary**: `DELETE /annotations/{id}` flips status to `40`, relocates files to `deleted_dir`, and emits a `Deleted` SSE event + outbox row. **Traces to**: AC-F-06, AC-F-07 **Note**: skipped until RB-01 + RB-08 land. --- ## Negative Scenarios ### FT-N-01: Create without image bytes **Summary**: `POST /annotations` with no `image` field is rejected. **Traces to**: AC-F-04 (negative) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with body missing `image` | HTTP 400 or 422; error envelope | **Max execution time**: 3s --- ### FT-N-02: Create without mediaType **Summary**: Missing required enum field is rejected. | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with no `mediaType` | HTTP 400 or 422; error envelope | **Max execution time**: 3s --- ### FT-N-03: Create without ANN policy **Summary**: A token with policy `DATASET` cannot create annotations. **Traces to**: AC-F-52 | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with an ES256 token carrying only the `DATASET` claim | HTTP 403; error envelope | **Max execution time**: 3s --- ### FT-N-04: Create unauthenticated **Summary**: Missing `Authorization` header → 401. | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with no `Authorization` header | HTTP 401; error envelope | **Max execution time**: 3s --- ### FT-N-05: Out-of-range bbox value (current lenient behavior) **Summary**: `centerX = 1.5` is accepted today; the test asserts the **current** behavior. Will flip to expecting 400/422 after SEC-05 lands. **Traces to**: documented gap in `security_approach.md` SEC-05 | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /annotations` with `detections[0].centerX = 1.5` | HTTP 200 today (lenient); test will be inverted post-SEC-05 | **Max execution time**: 3s --- ### FT-N-06: GET nonexistent annotation | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations/00000000000000000000000000000000` | HTTP 404; error envelope; `error.code` matches `/not.?found/i` | **Max execution time**: 3s --- ### FT-N-07: Filter by unknown mission | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations?missionId=` | HTTP 200; `body.length == 0` | **Max execution time**: 3s --- ### FT-N-08: SSE without auth | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Open SSE to `/annotations/events?missionId=` with no `Authorization` | HTTP 401 on connection establishment | **Max execution time**: 3s --- ### FT-N-09: Bearer token — expired | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations` with `Authorization: Bearer ` | HTTP 401; error envelope | **Max execution time**: 3s --- ### FT-N-10: Bearer token — wrong issuer | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations` with `Authorization: Bearer ` | HTTP 401; error envelope | **Max execution time**: 3s --- ### FT-N-11: Bearer token — wrong audience | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /annotations` with `Authorization: Bearer ` | HTTP 401; error envelope | **Max execution time**: 3s --- ### FT-N-12: Mutating settings without ADM | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `PUT /settings/system` with an ES256 token carrying only the `ANN` claim | HTTP 403; error envelope | **Max execution time**: 3s --- ### FT-N-13: PUT directories without ADM | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `PUT /settings/directories` with non-ADM JWT | HTTP 403; error envelope | **Max execution time**: 3s --- ### FT-N-14: Media upload missing waypoint | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /media` multipart without `waypointId` | HTTP 400 or 422; error envelope | **Max execution time**: 3s --- ### FT-N-15: Media upload without ANN | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /media` with non-ANN JWT | HTTP 403; error envelope | **Max execution time**: 3s --- ### FT-N-16: Bulk status with empty list | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /dataset/status/bulk` with `annotationIds: []` | HTTP 400; error envelope (verified: `DatasetService.BulkUpdateStatus` throws `ArgumentException`) | **Max execution time**: 3s