Files
satellite-provider/_docs/02_document/tests/blackbox-tests.md
T
Oleksandr Bezdieniezhnykh 5d84d2839e
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-505] Test-spec sync + task-mode doc updates for cycle 6
Step 12 (Test-Spec Sync, cycle-update mode):
- blackbox-tests.md: append BT-23..BT-26 for AZ-505's new
  observable behaviors (inventory order/shape; leaflet
  most-recent via location_hash; HTTP/2 multiplex over TLS+ALPN;
  request validation).
- performance-tests.md: append PT-09 (inventory p95 ≤ 1000ms /
  2500 tiles); records cycle-6 measured p95=66ms; documents
  promotion path to scripts/run-performance-tests.sh if budget
  ever tightens.
- traceability-matrix.md: resolve the 5 AZ-503 deferrals
  (AC-5/6/9/10/12) by pointing at AZ-505 test names + add 7
  AZ-505 AC rows (AC-1..AC-7) + bump totals (90 -> 94 tests,
  56/56 -> 63/63 in-scope) + add cycle-6 coverage shape notes
  (budget relaxation rationale, voting-filter deferral note,
  TLS+ALPN pivot, NFR propagation).

Step 13 (Update Docs, task mode):
- common_dtos.md: add 5 new TileInventory DTOs.
- common_interfaces.md: add ITileService.GetInventoryAsync.
- services_tile_service.md: document TileService.GetInventoryAsync
  steps + the XOR-validation-in-handler note.
- dataaccess_migrator.md: bump migration count 14 -> 15;
  describe migration 015 (AZ-505 leaflet covering index, lock
  window, INCLUDE-list trade-off).
- system-flows.md: add F7 (Leaflet Tile Serving, AZ-310 +
  AZ-505 location_hash rewire + TLS+ALPN) and F8 (Tile
  Inventory Bulk Lookup) with sequence diagrams, validation
  surface, and AC-4 perf evidence. Update Flow Inventory +
  Dependencies tables accordingly.
- glossary.md: add "Tile Inventory" entry pointing at the
  v1.0.0 contract.
- ripple_log_cycle6.md: new file — exhaustive reverse-dependency
  analysis confirms zero stale downstream module docs.

Advance autodev state from step 11 -> 14 (skipping 12+13 as
completed in this commit; auto-chain through Step 14 = Security
Audit optional gate).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 22:29:22 +03:00

247 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Blackbox Test Scenarios
## BT-01: Single Tile Download
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18
**Precondition**: Tile not in cache
**Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...`
**Pass criterion**: All fields present and correct values
## BT-02: Tile Cache Reuse
**Trigger**: Same GET as BT-01 repeated
**Precondition**: BT-01 completed (tile now cached)
**Expected**: HTTP 200; same tile ID returned; no new file created
**Pass criterion**: tile.Id matches first request's tile.Id
## BT-03: Region Request (200m, zoom 18, no stitch)
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=200, zoomLevel=18, stitchTiles=false
**Expected**: HTTP 200 immediately; status transitions: pending → processing → completed
**Pass criterion**: Final status="completed"; csvFilePath non-empty; summaryFilePath non-empty; tilesDownloaded + tilesReused > 0
**Timeout**: 240s
## BT-04: Region Request (400m, zoom 17, no stitch)
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=400, zoomLevel=17, stitchTiles=false
**Expected**: Same as BT-03
**Pass criterion**: Same as BT-03
**Timeout**: 240s
## BT-05: Region with Stitching (500m, zoom 18)
**Trigger**: POST /api/satellite/request with lat=47.461747, lon=37.647063, sizeMeters=500, zoomLevel=18, stitchTiles=true
**Expected**: Completes with stitched image generated
**Pass criterion**: status="completed"; stitched image file exists and size > 1024 bytes
**Timeout**: 240s
## BT-06: Simple Route Creation (2 points)
**Trigger**: POST /api/satellite/route with 2 waypoints (48.276067,37.384458) → (48.270740,37.374029), regionSize=500, zoom=18
**Expected**: Route created with interpolated intermediate points
**Pass criterion**: totalPoints > 2; every point spacing ≤ 200m; first point type="original"; last point type="original"; intermediates type="intermediate"
## BT-07: Route Retrieval by ID
**Trigger**: GET /api/satellite/route/{id} after BT-06
**Expected**: Same route returned with all points
**Pass criterion**: route.Id matches; points count matches creation response
## BT-08: Route with Map Processing
**Trigger**: POST /api/satellite/route with requestMaps=true, 2 points, regionSize=300
**Expected**: Route maps processed, stitched image and CSV created
**Pass criterion**: mapsReady=true; stitchedImagePath non-empty; csvFilePath non-empty; stitched image > 1024 bytes
**Timeout**: 180s
## BT-09: Route with Tiles ZIP
**Trigger**: POST /api/satellite/route with requestMaps=true, createTilesZip=true, 2 points
**Expected**: ZIP file created with tiles
**Pass criterion**: tilesZipPath non-empty; ZIP > 1024 bytes; ZIP entry count = unique tiles in CSV; entries start with "tiles/"; path has ≥5 parts (directory structure preserved)
**Timeout**: 180s
## BT-10: Complex Route (10 points, maps)
**Trigger**: POST /api/satellite/route with 10 waypoints, requestMaps=true, regionSize=300
**Expected**: All points interpolated; map tiles processed
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes
**Timeout**: 240s
## BT-11: Route with Geofences (10 points + 2 rectangles)
**Trigger**: POST /api/satellite/route with 10 waypoints + 2 geofence polygons, requestMaps=true
**Expected**: Geofence regions created and processed
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 10; stitched image > 1024 bytes; geofence regions linked to route
**Timeout**: 240s
## BT-12: Extended Route (20 points, maps)
**Trigger**: POST /api/satellite/route with 20 waypoints in separate geographic area, requestMaps=true
**Expected**: Large route processed completely
**Pass criterion**: mapsReady=true; uniqueTileCount ≥ 20; stitched image > 1024 bytes
**Timeout**: 360s
## Negative Scenarios
## BT-N01: Invalid Coordinates (out of range)
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=91&Longitude=181&ZoomLevel=18
**Expected**: Error response
**Pass criterion**: HTTP 4xx or error in response body
## BT-N02: Invalid Zoom Level
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.46&Longitude=37.64&ZoomLevel=25
**Expected**: Error response
**Pass criterion**: HTTP 4xx or error indicating invalid zoom
## BT-N03: Route with < 2 Points
**Trigger**: POST /api/satellite/route with only 1 point
**Expected**: Validation error
**Pass criterion**: HTTP 400 or validation error message
## BT-N04: Geofence with Invalid Coordinates (0,0)
**Trigger**: POST /api/satellite/route with geofence NW=(0,0) SE=(0,0)
**Expected**: Validation error
**Pass criterion**: Error message mentioning coordinates cannot be (0,0)
## BT-N05: Geofence with Inverted Corners
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat
**Expected**: Validation error
**Pass criterion**: Error message about northWest latitude > southEast latitude
---
## Cycle 2 — AZ-488 UAV Tile Upload (POST /api/satellite/upload)
All Cycle-2 UAV scenarios run with a JWT containing `permissions: ["GPS"]` (per AZ-487 + AZ-488). Files use the contract at `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0; per-item correlation is by ordinal index between metadata array and `IFormFileCollection`.
## BT-13: UAV Upload — Happy-Path 1-Item Batch Persists `source='uav'`
**Trigger**: POST `/api/satellite/upload` with a 1-item batch — a 256×256 JPEG (~50 KiB), `capturedAt = now`, valid coordinates inside the test region.
**Precondition**: Empty `tiles` table for the chosen cell; valid `GPS` JWT.
**Expected**: HTTP 200; response body has `items[0].status == "accepted"` and a non-empty `tileId`; a new row exists in `tiles` with `source='uav'`, `captured_at` matching the request (UTC, ≤ 1 s drift), `file_path == 'tiles/uav/{z}/{x}/{y}.jpg'`; the file exists on disk at that path with the uploaded bytes.
**Pass criterion**: All of the above true.
**AC trace**: AZ-488 AC-1.
## BT-14: UAV Upload — 3-Item Mixed Batch Returns Per-Item Results
**Trigger**: POST `/api/satellite/upload` with a 3-item batch where item-1 is a valid 256×256 JPEG, item-2 is a 512×512 JPEG (wrong dimensions), item-3 has PNG magic bytes (wrong format).
**Precondition**: Empty `tiles` table; valid `GPS` JWT.
**Expected**: HTTP 200; `items[0].status == "accepted"` with a `tileId`; `items[1].status == "rejected"` with `rejectReason == "WRONG_DIMENSIONS"`; `items[2].status == "rejected"` with `rejectReason == "INVALID_FORMAT"`. Exactly one new row appears in `tiles` (for item-1 only). No file written for items 2 or 3.
**Pass criterion**: status array matches `[accepted, rejected, rejected]` AND reasons match exactly AND `COUNT(*) WHERE source='uav'` == 1 for the test region.
**AC trace**: AZ-488 AC-2, AC-7a, AC-7c.
## BT-15: UAV Upload — Multi-Source Coexistence with `google_maps`
**Trigger**: Pre-seed `tiles` (raw INSERT) with a `source='google_maps'` row at `(L, Ln, z=18, size_m=200)` and `captured_at = T1 = now 2h`. Then POST `/api/satellite/upload` with a UAV tile for the same cell and `capturedAt = T2 = now`.
**Precondition**: AZ-484 migration 013 applied (5-column unique index in place); valid `GPS` JWT.
**Expected**: HTTP 200; both rows exist in `tiles` after upload (no overwrite of the google_maps row); a follow-up `GetByTileCoordinatesAsync(L, Ln, 18, 200)` returns the `source='uav'` row (per AZ-484 selection rule: max `captured_at` across sources).
**Pass criterion**: `SELECT source FROM tiles WHERE ...` returns both `'google_maps'` AND `'uav'`; the repository read returns the UAV row.
**AC trace**: AZ-488 AC-3; cross-references AZ-484 AC-1 (storage Inv-3) and AZ-484 AC-2 (selection rule).
## BT-16: UAV Upload — Same-Source UPSERT Collapses to One Row
**Trigger**: POST `/api/satellite/upload` with a UAV tile for cell `(L, Ln, 18, 200)` at `capturedAt = T1 = now 30m`, then a second POST for the same cell at `capturedAt = T2 = now` with different image bytes (different `seed`).
**Precondition**: Cell is empty for `source='uav'` before T1; valid `GPS` JWT.
**Expected**: HTTP 200 for both calls. After the second call, exactly one `source='uav'` row remains for the cell with `captured_at == T2`; the JPEG at `./tiles/uav/{z}/{x}/{y}.jpg` is overwritten with the T2 bytes. Any pre-existing `source='google_maps'` row is untouched.
**Pass criterion**: `SELECT COUNT(*) FROM tiles WHERE source='uav' AND (L, Ln, 18, 200)` == 1 AND `MAX(captured_at) ≈ T2` AND on-disk JPEG checksum matches the T2 upload.
**AC trace**: AZ-488 AC-4; cross-references AZ-484 AC-3.
## BT-17: UAV Upload — Quality-Gate Rule-Ordering Determinism
**Trigger**: POST `/api/satellite/upload` with a single item that violates BOTH Rule 1 (PNG magic instead of JPEG) AND Rule 3 (512×512 dimensions). Authenticated with `GPS` permission.
**Expected**: HTTP 200; `items[0].status == "rejected"` with `rejectReason == "INVALID_FORMAT"` (Rule 1 fires first; Rule 3 never evaluated).
**Pass criterion**: rejectReason equals exactly `INVALID_FORMAT`; never `WRONG_DIMENSIONS`.
**AC trace**: AZ-488 AC-7; rule-ordering invariant from `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0.
## Cycle 2 — AZ-487 Endpoint Parity (existing endpoints with Bearer token)
## BT-18: Existing Tile Endpoint Returns Identical Body with Valid Bearer
**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` with a valid Bearer token.
**Precondition**: Tile may or may not be cached.
**Expected**: Response body is structurally identical to BT-01 (`tileId`, `zoomLevel == 18`, `tileSizePixels == 256`, `imageType == "jpg"`, `filePath` matches `tiles/18/*/*`).
**Pass criterion**: status == 200 AND BT-01's pass criterion AND no behavioral change vs pre-AZ-487 baseline.
**AC trace**: AZ-487 AC-4 (handler unchanged); validates AZ-487 AC-8 (existing suite parity).
---
## Cycle 5 — AZ-503 Tile Identity → UUIDv5 + integer UPSERT (foundation)
All Cycle-5 UAV scenarios reuse the AZ-488 envelope. The new observable surface is: a `flightId` field in `UavTileMetadata`, deterministic `tileId` / `locationHash` values, and a per-flight on-disk layout `./tiles/uav/{flight_id|none}/{z}/{x}/{y}.jpg`. No new HTTP route or wire change beyond the optional metadata field.
## BT-19: UAV Upload — Multi-Flight Coexistence with Shared `location_hash`
**Trigger**: POST `/api/satellite/upload` twice for the SAME `(z=18, tile_x, tile_y, tile_size_meters)` from two different `flight_id` values `F1 ≠ F2` (each upload sends `metadata.flightId = F`, valid 256×256 JPEG, fresh `capturedAt`).
**Precondition**: Empty `tiles` table for the chosen cell; valid `GPS` JWT.
**Expected**: HTTP 200 for both calls. After the second call, exactly TWO rows exist in `tiles` for `source='uav'` at the cell — one per flight. Both rows share the same `location_hash` (deterministic per `{z}/{x}/{y}`); each has a distinct `id` (deterministic per `{z}/{x}/{y}/uav/{flight_id}`). On disk, two files exist at `./tiles/uav/{F1}/{z}/{x}/{y}.jpg` and `./tiles/uav/{F2}/{z}/{x}/{y}.jpg`; `rm -rf ./tiles/uav/{F1}/` removes ONLY Flight F1's evidence.
**Pass criterion**: `SELECT COUNT(*) FROM tiles WHERE source='uav' AND (z,x,y,size_m)=(...)` == 2 AND `COUNT(DISTINCT location_hash)` == 1 AND `COUNT(DISTINCT id)` == 2 AND `COUNT(DISTINCT file_path)` == 2 AND both files present on disk.
**AC trace**: AZ-503 AC-3, AC-11.
## BT-20: UAV Upload — Idempotent Re-Insert Preserves Deterministic `tileId` and `content_sha256`
**Trigger**: POST `/api/satellite/upload` for cell `(z=18, x, y, size_m)` with `flightId = F` and JPEG body `B` and `capturedAt = T1`. Repeat the SAME body and `flightId` with `capturedAt = T2 > T1`.
**Precondition**: Empty `tiles` table for the cell; valid `GPS` JWT.
**Expected**: HTTP 200 for both calls. Exactly ONE row exists for the cell after both inserts. The row's `id` is identical before and after the second insert (deterministic UUIDv5 from `{z}/{x}/{y}/uav/{F}`). `updated_at` advances to T2; `created_at` is NOT regenerated. `content_sha256` equals `SHA-256(B)` externally computed; the second insert's `content_sha256` matches the first (byte-identical body).
**Pass criterion**: `SELECT COUNT(*)` == 1 AND `id` is stable AND `content_sha256 == sha256(B)` AND `created_at` unchanged AND `updated_at == T2`.
**AC trace**: AZ-503 AC-2, AC-7.
## BT-21: UAV Upload — Float-Rounded Coordinates Collapse to a Single Row
**Trigger**: POST `/api/satellite/upload` for cell `(z=18, x, y, size_m)` with `latitude = 47.123456789012345` and `flightId = F`. Repeat with the SAME `(x, y, z)` but `latitude` recomputed from `tile_center = TileToWorldPos(x, y, z)` (slightly different float representation).
**Precondition**: Empty `tiles` table for the cell; valid `GPS` JWT.
**Expected**: HTTP 200 for both calls. Exactly ONE row results — the integer-only UPSERT conflict key (`tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, ...)`) triggers despite the float-different `latitude` values.
**Pass criterion**: `SELECT COUNT(*) FROM tiles WHERE source='uav' AND tile_zoom=18 AND tile_x=x AND tile_y=y AND tile_size_meters=size_m` == 1.
**AC trace**: AZ-503 AC-4.
## BT-22: Migration 014 — Identity Columns Land and Backfill Is Deterministic
**Trigger**: Run `dotnet ef migrations apply` (via API container startup) against a DB with cycle-4 schema; then query `information_schema.columns` and `pg_indexes` for `tiles`.
**Precondition**: DB starts with migration 013 applied (cycle 4 baseline); `pgcrypto` available.
**Expected**: `tiles` table has `flight_id uuid NULL`, `location_hash uuid NOT NULL`, `content_sha256 bytea NULL`, `legacy_id uuid NULL`. `idx_tiles_unique_identity` exists as a UNIQUE index over `(tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-...'::uuid))`. AZ-484 index `idx_tiles_unique_location_source` is dropped. For any pre-existing row, `location_hash` equals `uuidv5(TILE_NAMESPACE, '{tile_zoom}/{tile_x}/{tile_y}')` byte-identically (validated against a SQL `pg_temp.uuidv5` reference function).
**Pass criterion**: All column / index assertions pass AND the deterministic backfill matches the reference function on 100% of sampled rows.
**AC trace**: AZ-503 AC-8.
## BT-23: Inventory Endpoint — Order Preserved Across Present/Absent Mix
**Trigger**: `POST /api/satellite/tiles/inventory` with a body of 25 interleaved `(z, x, y)` coords at zoom 18, where 12 of the 25 are seeded into `tiles` (mix of `google_maps` and per-flight `uav` rows) and 13 are absent. Valid JWT attached.
**Precondition**: Migration 015 applied (`tiles_leaflet_path` index exists); rows seeded via `TileRepository.AddAsync` so `location_hash` is populated.
**Expected**: HTTP 200; response body is a `TileInventoryResponse` with `results.length == 25`; entries appear in the **same order** as the request body; 12 entries have `present=true` with `id` / `locationHash` / `capturedAt` / `source` populated; 13 entries have `present=false` with only `locationHash` populated (UUIDv5 of `"{z}/{x}/{y}"`), `id` is `null`, and `capturedAt` / `source` / `flightId` / `resolutionMPerPx` are all `null`.
**Pass criterion**: All ordering, presence, and field-shape invariants from `tile-inventory.md` v1.0.0 Inv-1..Inv-6 hold for every entry.
**AC trace**: AZ-505 AC-1 (resolves AZ-503 AC-5 deferral).
## BT-24: Leaflet Read Path — Most-Recent Selection Keyed on location_hash
**Trigger**: `GET /tiles/{z}/{x}/{y}` against a cell that has two seeded rows for the same `(z, x, y)`: a `google_maps` row with `captured_at = T - 2h` and a `uav` row with `captured_at = T - 30min` (strictly newer) and a distinct `flight_id`.
**Precondition**: Migration 015 applied; both rows persisted with their `location_hash` populated via `Uuidv5.LocationHashForTile`.
**Expected**: The DB-level SELECT that `TileRepository.GetByTileCoordinatesAsync` runs (`SELECT … FROM tiles WHERE location_hash = $1 ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1`) returns exactly **one** row — the UAV row.
**Pass criterion**: `picked.id == uavId` (the newer row wins); selection semantics are byte-identical to AZ-484 / AZ-503-foundation, only the access key changed from the `(tile_zoom, tile_x, tile_y, tile_size_meters)` 4-tuple to `location_hash`.
**AC trace**: AZ-505 AC-2 (resolves AZ-503 AC-6 deferral).
## BT-25: HTTP/2 Multiplexed Tile Reads on a Single TLS Connection
**Trigger**: A single `HttpClient` configured with `SocketsHttpHandler { EnableMultipleHttp2Connections = false }` + `HttpVersion.Version20` + `HttpVersionPolicy.RequestVersionExact` issues 20 concurrent `GET /tiles/{z}/{x}/{y}` requests for the same well-known tile.
**Precondition**: Kestrel configured with `HttpProtocols.Http1AndHttp2`; dev listener bound to `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated by `scripts/run-tests.sh`); the integration-test container trusts the cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. ALPN advertises `h2` and `http/1.1`.
**Expected**: All 20 responses succeed with HTTP 200; each `HttpResponseMessage.Version` equals `2.0`; per-tile `ETag` and `Cache-Control` headers are preserved unchanged from the HTTP/1.1 baseline; all 20 streams share a single TLS connection (enforced by `EnableMultipleHttp2Connections = false`).
**Pass criterion**: 20/20 responses are `Status=200`, `Version=2.0`, with non-null `ETag` and non-null `Cache-Control`.
**Note (post-merge correction)**: Original AZ-505 wording was "dev plaintext endpoint" / h2c; switched to TLS+ALPN during the cycle-6 Run-Tests step because Kestrel silently downgrades `Http1AndHttp2` to HTTP/1.1 over plaintext (ALPN cannot run unencrypted). See `_docs/03_implementation/implementation_report_tile_inventory_cycle6.md` → "Post-merge correction".
**AC trace**: AZ-505 AC-5 (resolves AZ-503 AC-12 deferral).
## BT-26: Inventory Endpoint — Request Validation
**Trigger**: Four `POST /api/satellite/tiles/inventory` calls, each exercising one validation rule.
**Precondition**: API up; valid JWT attached except for the anonymous case.
**Expected**:
- Both `tiles` and `locationHashes` populated → HTTP 400 with a descriptive `detail` per `tile-inventory.md` Inv-1.
- Neither `tiles` nor `locationHashes` populated → HTTP 400.
- `tiles.length > 5000` or `locationHashes.length > 5000` → HTTP 400 (the 5000 cap matches `TileInventoryLimits.MaxEntriesPerRequest`, Inv-7).
- No `Authorization: Bearer …` header → HTTP 401 before the handler runs (matches the `.RequireAuthorization()` baseline; same shape as SEC-05).
**Pass criterion**: All four expected status codes returned; no response leaks server internals.
**AC trace**: AZ-505 AC-6.