# Blackbox Tests > **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14). > **Naming**: post-rename target. Pre-rename code path runs the same test against `/aircrafts`/`/flights`; tests will be RED until B5–B8 land. > **Cross-references**: every test traces to one or more AC IDs from `_docs/00_problem/acceptance_criteria.md`. Expected results are inline (quantifiable) with a pointer to the corresponding row in `_docs/00_problem/input_data/expected_results/results_report.md`. ## Positive Scenarios ### FT-P-01: Create non-default vehicle returns 201 with PascalCase body **Summary**: Verifies vehicle CRUD create path and response wire shape. **Traces to**: AC-1.1, AC-8.1 **Category**: Vehicle CRUD **Preconditions**: - `seed_empty` **Input data**: `data_parameters.md` § 2.1 — `POST /vehicles { Type:0, Model:"Bayraktar", Name:"BR-01", FuelType:1, BatteryCapacity:0, EngineConsumption:5, EngineConsumptionIdle:1, IsDefault:false }`, JWT `permissions=FL` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /vehicles` with body above | `201 Created`; body `Vehicle` with PascalCase keys `Id, Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault`; `Id` parses as UUID | | 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={returnedId}` | `count == 1` | **Expected outcome**: results_report.md AC-1 row 1.1. **Max execution time**: 5s. --- ### FT-P-02: Create default vehicle demotes prior default **Summary**: Verifies the clear-then-set pattern in `VehicleService.CreateVehicle` when `IsDefault=true`. **Traces to**: AC-1.2 **Category**: Vehicle CRUD **Preconditions**: - `seed_one_default_vehicle` (prior row `Id=P1`, `is_default=true`) **Input data**: `POST /vehicles { ..., IsDefault:true }` (other fields valid) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /vehicles { ..., IsDefault:true }` | `201`; new row has `IsDefault:true` | | 2 | Side-channel `SELECT id, is_default FROM vehicles ORDER BY is_default DESC` | New row has `is_default=true`; row `P1` has `is_default=false`; total `is_default=true` count == 1 | **Expected outcome**: results_report.md AC-1 row 1.2. **Max execution time**: 5s. --- ### FT-P-03: setDefault promotes existing vehicle **Summary**: Verifies `POST /vehicles/{id}/setDefault` clear-then-set behavior. **Traces to**: AC-1.2 (update branch), AC-1.4 **Category**: Vehicle CRUD **Preconditions**: - `seed_one_default_vehicle` (default row `P1`); add a non-default row `P2` **Input data**: `POST /vehicles/{P2}/setDefault { IsDefault:true }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `POST /vehicles/{P2}/setDefault { IsDefault:true }` | `200`; body `Vehicle` with `Id==P2`, `IsDefault==true` | | 2 | Side-channel `SELECT id, is_default FROM vehicles` | `P2.is_default==true`, `P1.is_default==false`, default count == 1 | **Expected outcome**: results_report.md AC-1 row 1.4. **Max execution time**: 5s. --- ### FT-P-04: Vehicle list returns plain JSON array (no pagination), ordered by Name ASC **Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions` — and that results are ordered alphabetically by `Name` ASC (per AircraftService.GetVehicles `OrderBy(a => a.Name)`). **Traces to**: AC-1.5 **Category**: Vehicle CRUD **Preconditions**: - `seed_3_vehicles_2_default` containing `BR-01`, `BR-02`, `MQ-9` (any insert order) **Input data**: `GET /vehicles` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) | **Expected outcome**: results_report.md AC-1 row 1.5. **Max execution time**: 2s. --- ### FT-P-05: Vehicle filter by name + isDefault (case-INSENSITIVE name) **Summary**: Verifies query-string filter — **case-INSENSITIVE** substring on `name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`), exact on `isDefault`. **Traces to**: AC-1.6 **Category**: Vehicle CRUD **Preconditions**: - `seed_3_vehicles_2_default` containing `BR-01` (default), `BR-02` (non-default), `MQ-9` (default) **Input data**: `GET /vehicles?name=BR&isDefault=true` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` | | 2 | `GET /vehicles?name=br&isDefault=true` (lowercase) | `200`; `body.length == 1`; `body[0].Name == "BR-01"` (case-INSENSITIVE match) | **Expected outcome**: results_report.md AC-1 row 1.6. **Max execution time**: 2s. --- ### FT-P-06: Delete vehicle with no references **Summary**: Verifies `DELETE /vehicles/{id}` returns 204 and removes the row. **Traces to**: AC-1.10 **Category**: Vehicle CRUD **Preconditions**: - One vehicle row exists, no missions reference it **Input data**: `DELETE /vehicles/{id}` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `DELETE /vehicles/{id}` | `204 No Content`; empty body | | 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 0` | **Expected outcome**: results_report.md AC-1 row 1.10. **Max execution time**: 2s. --- ### FT-P-07: Create mission with default CreatedDate **Summary**: Verifies mission create assigns `CreatedDate = UtcNow` when null. **Traces to**: AC-2.1 **Category**: Mission CRUD **Preconditions**: - `seed_one_default_vehicle` (use its id as `VehicleId`) **Input data**: `POST /missions { Name:"Recon-01", VehicleId:, CreatedDate:null }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Capture `t0 = DateTime.UtcNow` | | | 2 | `POST /missions` with body above | `201`; `body.CreatedDate` parses as UTC; `abs(body.CreatedDate - t0) ≤ 5s` | **Expected outcome**: results_report.md AC-2 row 2.1. **Max execution time**: 5s. --- ### FT-P-08: Mission list paginated default page, ordered by CreatedDate DESC **Summary**: Verifies `GET /missions` returns `PaginatedResponse` with default page size 20, ordered by `CreatedDate` DESC (newest first per `FlightService.GetMissions` `OrderByDescending(f => f.CreatedDate)`). **Traces to**: AC-2.3, AC-8.7 **Category**: Mission CRUD **Preconditions**: - `seed_25_missions` with deterministic `CreatedDate` values spanning January-February 2026 **Input data**: `GET /missions` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC ordering) | | 2 | `GET /missions?name=re` (lowercase) against missions containing `"Recon-*"` names | `200`; `body.TotalCount > 0` — case-INSENSITIVE name filter matches Mission Name `"Recon-*"` | **Expected outcome**: results_report.md AC-2 row 2.3. **Max execution time**: 2s. --- ### FT-P-09: Mission list page 2 **Summary**: Verifies pagination skips correctly to page 2. **Traces to**: AC-2.3 **Category**: Mission CRUD **Preconditions**: - `seed_25_missions` **Input data**: `GET /missions?page=2&pageSize=20` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /missions?page=2&pageSize=20` | `200`; `Page==2`; `Items.length==5`; ids in `Items` are disjoint from those returned by FT-P-08 (per UUID set check) | **Expected outcome**: results_report.md AC-2 row 2.4. **Max execution time**: 2s. --- ### FT-P-10: Mission list date range **Summary**: Verifies `?fromDate=&toDate=` filter inclusivity. **Traces to**: AC-2.3 **Category**: Mission CRUD **Preconditions**: - `seed_25_missions` (5 January, 20 February) **Input data**: `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `200`; `TotalCount==5`; every `Items[i].CreatedDate` falls within January 2026 UTC | **Expected outcome**: results_report.md AC-2 row 2.5. **Max execution time**: 2s. --- ### FT-P-11: Mission partial update preserves null fields **Summary**: Verifies `PUT /missions/{id}` only overwrites non-null fields in the request body. **Traces to**: AC-2.5 **Category**: Mission CRUD **Preconditions**: - One mission row with known `Name`, `VehicleId` **Input data**: `PUT /missions/{id} { Name:"Renamed", VehicleId:null }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `200`; `body.Name == "Renamed"`; `body.VehicleId == previous_value` (preserved) | **Expected outcome**: results_report.md AC-2 row 2.7. **Max execution time**: 2s. --- ### FT-P-12: Mission cascade delete walks every dependency table **Summary**: Verifies `DELETE /missions/{id}` deletes every row in the seeded chain across `map_objects`, `detection`, `annotations`, `media`, `waypoints`, `missions`. **Traces to**: AC-3.1 **Category**: Mission Cascade Delete (F3) — most critical **Preconditions**: - `fixture_cascade_F3` applied (seeded `mid` mission with full chain) **Input data**: `DELETE /missions/{mid}` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `204` | | 2 | Side-channel `SELECT COUNT(*) FROM map_objects WHERE mission_id={mid}` | `count == 0` | | 3 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (seeded ids)` | `count == 0` | | 4 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (seeded ids)` | `count == 0` | | 5 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (seeded ids)` | `count == 0` | | 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE mission_id={mid}` | `count == 0` | | 7 | Side-channel `SELECT COUNT(*) FROM missions WHERE id={mid}` | `count == 0` | | 8 | Compare per-table counts against `expected_results/cascade_F3_walk.json` | `json_diff` matches all expected fields | **Expected outcome**: results_report.md AC-3 row 3.1. **Max execution time**: 10s. --- ### FT-P-13: Waypoint list ordered by OrderNum **Summary**: Verifies `GET /missions/{id}/waypoints` returns waypoints sorted by `OrderNum` ASC, regardless of insert order. **Traces to**: AC-4.3 **Category**: Waypoint CRUD **Preconditions**: - `seed_5_waypoints_unordered` under one mission **Input data**: `GET /missions/{id}/waypoints` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `200`; body parses as JSON array; `body.length == 5`; `[w.OrderNum for w in body] == [1, 2, 3, 4, 5]` | **Expected outcome**: results_report.md AC-4 row 4.2. **Max execution time**: 2s. --- ### FT-P-14: Waypoint create echoes geo fields **Summary**: Verifies `POST /missions/{id}/waypoints` creates a row with the supplied `GeoPoint` and DOES NOT auto-convert lat/lon ↔ mgrs. **Traces to**: AC-4 (data_parameters.md § 2.3 spec divergence) **Category**: Waypoint CRUD **Preconditions**: - One mission row exists **Input data**: `POST /missions/{id}/waypoints { GeoPoint:{Lat:50.45, Lon:30.52, Mgrs:null}, WaypointSource:0, WaypointObjective:0, OrderNum:1, Height:120 }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `201`; `body.GeoPoint.Lat == 50.45`; `body.GeoPoint.Lon == 30.52`; `body.GeoPoint.Mgrs == null` (NO auto-conversion today) | **Expected outcome**: results_report.md AC-4 row 4.3. **Max execution time**: 2s. --- ### FT-P-15: Waypoint update is full overwrite **Summary**: Verifies `PUT /missions/{id}/waypoints/{wpId}` overwrites every field even though the DTO looks "partial" (non-nullable enums/numerics). **Traces to**: AC-4.4 **Category**: Waypoint CRUD **Preconditions**: - One waypoint exists with `Height=120`, `OrderNum=1`, `GeoPoint=(Lat:50.45, ...)` **Input data**: `PUT /missions/{id}/waypoints/{wpId} { GeoPoint:null, WaypointSource:1, WaypointObjective:1, OrderNum:2, Height:0 }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `200`; `body.Height == 0` (overwritten from 120); `body.OrderNum == 2`; `body.GeoPoint == null` | **Expected outcome**: results_report.md AC-4 row 4.4. **Max execution time**: 2s. --- ### FT-P-16: Health is 200 anonymous **Summary**: Verifies `GET /health` requires no auth. **Traces to**: AC-7.1, AC-7.2 **Category**: Health probe **Preconditions**: any **Input data**: `GET /health` (no `Authorization` header) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /health` with no `Authorization` header | `200`; body == `{ "status": "healthy" }` exactly (case-sensitive key) | **Expected outcome**: results_report.md AC-7 row 7.1. **Max execution time**: 2s. --- ### FT-P-17: Health is 200 with Postgres stopped (process-liveness only) **Summary**: Verifies the health probe does NOT ping the DB. **Traces to**: AC-7.2, AC-7.3 **Category**: Health probe **Preconditions**: `missions` running; `postgres-test` `docker compose stop postgres-test` **Input data**: `GET /health` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /health` with PG stopped | `200`; body == `{ "status": "healthy" }` | **Expected outcome**: results_report.md AC-7 row 7.2. **Max execution time**: 5s (allow PG-stop time). --- ### FT-P-18: Waypoint cascade delete is scoped to one waypoint **Summary**: Verifies `DELETE /missions/{mid}/waypoints/{wpId}` deletes only the chain rooted at `wpId`, leaves sibling waypoint chains intact. **Traces to**: AC-4.5 **Category**: Waypoint Cascade Delete (F4) **Preconditions**: - `fixture_cascade_F4` (target waypoint `wp1` with chain; sibling waypoint `wp2` with chain) **Input data**: `DELETE /missions/{mid}/waypoints/{wp1}` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `204` | | 2 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (wp1 chain)` | `count == 0` | | 3 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (wp1 chain)` | `count == 0` | | 4 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (wp1 chain)` | `count == 0` | | 5 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp1}` | `count == 0` | | 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp2}` | `count == 1` (sibling intact) | | 7 | Side-channel counts on `media`, `annotations`, `detection` for `wp2` chain | all `> 0` (sibling intact) | | 8 | Compare against `cascade_F4_walk.json` | `json_diff` matches | **Expected outcome**: results_report.md AC-4 row 4.5. **Max execution time**: 10s. --- ## Negative Scenarios ### FT-N-01: Vehicle name filter returns empty when no row matches case-insensitively **Summary**: Verifies that `?name=` returns an empty body when no row's `Name` contains the substring (case-insensitive). This is the "no-match" half of AC-1.6 — distinct from FT-P-05 which asserts that lowercase input DOES match `BR-01`. **Traces to**: AC-1.6 **Category**: Vehicle CRUD (negative) **Preconditions**: - `seed_3_vehicles_2_default` (`BR-01`, `BR-02`, `MQ-9`) **Input data**: `GET /vehicles?name=ZZ` (substring `ZZ` is absent from every name) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `GET /vehicles?name=ZZ` | `200`; `body.length == 0` | | 2 | `GET /vehicles?name=zz` (lowercase) | `200`; `body.length == 0` (still no match) | **Expected outcome**: results_report.md AC-1 row 1.7. **Note (drift, 2026-05-14)**: this test was previously titled "Vehicle name filter is case-sensitive" and asserted `?name=br → length 0`. That assertion was WRONG against the actual code (`a.Name.ToLower().Contains(query.Name.ToLower())` — case-insensitive). The test is rewritten to assert the genuine no-match case. **Max execution time**: 2s. --- ### FT-N-02: GET vehicle 404 **Summary**: Verifies `GET /vehicles/{random}` returns 404 with the documented envelope. **Traces to**: AC-1.7, AC-8.2 **Category**: Vehicle CRUD (negative) **Preconditions**: any **Input data**: `GET /vehicles/{random uuid}`, JWT `FL` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `404`; body parses to JSON object with EXACTLY keys `statusCode, message` (camelCase by accidental match) | **Expected outcome**: results_report.md AC-1 row 1.8. **Max execution time**: 2s. --- ### FT-N-03: Delete vehicle in use returns 409 **Summary**: Verifies `DELETE /vehicles/{id}` returns 409 when at least one mission references the vehicle. **Traces to**: AC-1.8, AC-8.5 **Category**: Vehicle CRUD (negative) **Preconditions**: - Vehicle row exists, ≥1 mission row references it **Input data**: `DELETE /vehicles/{id}` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | `DELETE /vehicles/{id}` | `409`; envelope `{ statusCode:409, message: }` | | 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 1` (row NOT deleted) | **Expected outcome**: results_report.md AC-1 row 1.9. **Max execution time**: 2s. --- ### FT-N-04: Create mission with non-existent VehicleId returns 400 (today) **Summary**: Verifies the existing `ArgumentException → 400` divergence (spec wants 404). **Traces to**: AC-2.2 (carry-forward divergence), AC-8.5 **Category**: Mission CRUD (negative) **Preconditions**: - `seed_empty` (no vehicles exist) **Input data**: `POST /missions { Name:"x", VehicleId:, CreatedDate:null }` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `400`; envelope `{ statusCode:400, message: }` | | 2 | Side-channel `SELECT COUNT(*) FROM missions` | `count == 0` (no row written) | **Expected outcome**: results_report.md AC-2 row 2.2. **Note**: this test will FAIL once the spec divergence is closed (status will become 404). When that work lands, update the expected status here. **Max execution time**: 2s. --- ### FT-N-05: GET mission 404 **Summary**: Verifies `GET /missions/{random}` returns 404. **Traces to**: AC-2.4, AC-8.2 **Category**: Mission CRUD (negative) **Preconditions**: any **Input data**: `GET /missions/{random uuid}`, JWT `FL` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `404`; envelope `{ statusCode:404, message: }` | **Expected outcome**: results_report.md AC-2 row 2.6. **Max execution time**: 2s. --- ### FT-N-06: Cascade delete short-circuits on missing mission (404 before any DELETE) **Summary**: Verifies that mission existence is checked BEFORE any dependency-table DELETE runs (AC-3.2). **Traces to**: AC-3.2 **Category**: Cascade delete F3 (negative) **Preconditions**: - `fixture_cascade_F3` applied (seeded chain rooted at `mid`); test targets a DIFFERENT random `mid'` not present in the DB **Input data**: `DELETE /missions/{mid'}` (random uuid) **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Enable Postgres `log_statement=all` (test orchestrator sets this on `postgres-test` startup) | | | 2 | `DELETE /missions/{mid'}` | `404` | | 3 | Side-channel `SELECT COUNT(*) FROM map_objects` | `count` unchanged from precondition | | 4 | Side-channel inspect `pg_stat_statements` (or scrape `pg_log` lines logged after the request timestamp) | NO `DELETE FROM map_objects` / `... waypoints` / `... media` / `... annotations` / `... detection` issued by the request connection | **Expected outcome**: results_report.md AC-3 row 3.2. **Max execution time**: 5s. --- ### FT-N-07: Waypoint operation against missing mission returns 404 **Summary**: Verifies parent-mission existence check on every waypoint endpoint. **Traces to**: AC-4.2 **Category**: Waypoint CRUD (negative) **Preconditions**: any **Input data**: `GET /missions/{random uuid}/waypoints`, JWT `FL` **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | request as above | `404`; envelope `{ statusCode:404, message: }` | **Expected outcome**: results_report.md AC-4 row 4.1. **Max execution time**: 2s. --- ### FT-N-08: Generic 500 returns redacted body, logs stack **Summary**: Verifies `Exception` fallthrough — body is the generic message, full stack is in the log. **Traces to**: AC-8.6, AC-10.3 **Category**: Wire shape (negative) **Preconditions**: - Inject a divide-by-zero or similar throw in any handler via a test-only middleware OR force the condition (e.g., AC-2.9 TOCTOU triggers `Npgsql.PostgresException` → 500). For deterministic execution, drop the `vehicles` table mid-test then call `GET /vehicles/{any}`. **Input data**: as above **Steps**: | Step | Consumer Action | Expected System Response | |------|----------------|------------------------| | 1 | Side-channel `DROP TABLE vehicles CASCADE` | | | 2 | `GET /vehicles/{any uuid}`, JWT `FL` | `500`; body == `{ "statusCode":500, "message":"Internal server error" }` exactly | | 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching log line emitted within `≤ 2s` of the request, containing the exception type name | **Expected outcome**: results_report.md AC-8 row 8.7 + AC-10 row 10.1. **Max execution time**: 5s. --- ## Notes - Tests that depend on the DB side-channel reading `pg_stat_statements` or query logs (FT-N-06) require a one-time test bootstrap to enable those features on `postgres-test`. That bootstrap is part of the docker compose for the test environment (`environment.md` § Docker Environment). - Every test enforces `Max execution time` via xUnit `[Fact(Timeout = N*1000)]`. Default suite timeout (CI gate) is 15 minutes (per `environment.md`).