Files
satellite-provider/_docs/02_document/tests/blackbox-tests.md
T
Oleksandr Bezdieniezhnykh 5e056b2334 [AZ-809] Strict validation for POST /api/satellite/route
Third concrete child of AZ-795 (cycle 8 batch 3). FluentValidation +
[JsonRequired] + UnmappedMemberHandling.Disallow combine to reject every
malformed payload at the API boundary with RFC 7807 ValidationProblemDetails.

Validators (SatelliteProvider.Api/Validators/, all new)
- CreateRouteRequestValidator: id non-empty, name/description length,
  regionSizeMeters/zoomLevel ranges, points count [2, 500], cross-field
  createTilesZip => requestMaps. Chains RoutePointValidator (per-point)
  and GeofencePolygonValidator (per-polygon, guarded by When(Geofences != null)).
  OverridePropertyName("geofences.polygons") on the geofences chain so
  FluentValidation's default leaf-only key policy doesn't drop the parent
  path on deep expressions like req.Geofences!.Polygons.
- RoutePointValidator: lat/lon ranges; OverridePropertyName("lat"/"lon")
  chained AFTER InclusiveBetween (the extension is defined on
  IRuleBuilderOptions<T, TProperty>, so the generic type is only
  inferable after the first concrete rule) so error keys match the
  wire format (`points[i].lat`) rather than the C# property name
  (`points[i].latitude`).
- GeofencePolygonValidator: per-corner range checks via private nested
  GeoCornerValidator; cross-field NW.Lat > SE.Lat and NW.Lon < SE.Lon
  invariants emit at errors["geofences.polygons[i].northWest"].

DTOs (SatelliteProvider.Common/DTO/, [JsonRequired] additions only)
- CreateRouteRequest: id, name, regionSizeMeters, zoomLevel, points,
  requestMaps, createTilesZip
- RoutePoint: Latitude, Longitude
- GeofencePolygon: NorthWest, SouthEast; Geofences: Polygons
- GeoPoint: Lat, Lon

Tests
- Unit: 26 methods total — 16 in CreateRouteRequestValidatorTests, 6 in
  GeofencePolygonValidatorTests, 4 in RoutePointValidatorTests. Each
  RuleFor/RuleForEach chain has at least one positive + one negative case.
- Integration: CreateRouteValidationTests.cs — 16 methods (happy + 15
  failure modes) wired into smoke + full suites. Covers empty body,
  missing/zero id, empty name, out-of-range regionSizeMeters/zoomLevel,
  points count < 2, per-point lat/lon out-of-range, geofence invariants,
  missing requestMaps, cross-field createTilesZip, unknown root field,
  nested type mismatch.
- Manual probe: scripts/probe_route_validation.sh curl-exercises every
  failure mode end-to-end + happy path.

Docs
- New contract _docs/02_document/contracts/api/route-creation.md v1.0.0
  with nested DTO chain, invariants, per-field test cases table, and
  advisories on the legacy service-layer RouteValidator + the
  input/output RoutePoint vs RoutePointDto naming asymmetry.
- system-flows.md F4 sequence diagram extended with the validation-filter
  branch; preconditions + error scenarios reference the new contract.
- modules/api_program.md: CreateRoute handler section added; Api/Validators
  bumped to AZ-808/AZ-809/AZ-811.
- modules/common_dtos.md: DTO descriptions updated with [JsonRequired]
  annotations and constraint summaries.
- tests/blackbox-tests.md BT-06/BT-N03/BT-N04/BT-N05 align with the new
  wire format and named error keys.
- tests/security-tests.md SEC-04 references GlobalExceptionHandler's
  JsonException branch + AZ-353 correlationId.
- _docs/03_implementation/batch_03_cycle8_report.md + reviews/batch_03_cycle8_review.md
  (PASS_WITH_NOTES — F1 Low: OverridePropertyName documented inline,
  F2 + F3 Info: pre-existing advisories for follow-up).

Smoke green (mode=smoke, exit 0). AZ-809 transitioned to In Testing on Jira.
Task file moved to _docs/02_tasks/done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:49:48 +03:00

287 lines
25 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?lat=47.461747&lon=37.647063&zoom=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 id=`<new-Guid>`, name=`<unique>`, 2 waypoints (48.276067,37.384458) → (48.270740,37.374029), regionSizeMeters=500, zoomLevel=18, requestMaps=false, createTilesZip=false. Post-AZ-809 (cycle 8) every `[JsonRequired]` axis must be present — see `_docs/02_document/contracts/api/route-creation.md` v1.0.0.
**Expected**: HTTP 200 + 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?lat=91&lon=181&zoom=18
**Expected**: Error response
**Pass criterion**: HTTP 4xx or error in response body
## BT-N02: Invalid Zoom Level
**Trigger**: GET /api/satellite/tiles/latlon?lat=47.46&lon=37.64&zoom=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 (post-AZ-809 wire format: `id`/`name`/`regionSizeMeters`/`zoomLevel`/`points`/`requestMaps`/`createTilesZip`).
**Expected**: HTTP 400 + `ValidationProblemDetails` per `error-shape.md` v1.0.0; `errors["points"]` map entry from `CreateRouteRequestValidator`.
**Pass criterion**: HTTP 400; response body `Content-Type: application/problem+json`; `errors["points"]` mentions the `[2, 500]` count constraint.
**AC trace**: AZ-809 AC-1 (rule 7).
## BT-N04: Geofence with Invalid Coordinates (0,0) — superseded by AZ-809
**Trigger**: POST /api/satellite/route with geofence NW=(0,0) SE=(0,0).
**Expected**: HTTP 400 + `ValidationProblemDetails`. Pre-AZ-809 behavior accepted (0,0) corners but caught the equal-corners case via the legacy `RouteValidator`. Post-AZ-809, `GeofencePolygonValidator` rejects equal corners because BOTH cross-field invariants (`NW.Lat > SE.Lat` and `NW.Lon < SE.Lon`) fail.
**Pass criterion**: HTTP 400; `errors["geofences.polygons[0].northWest"]` contains both the lat and lon invariant messages.
**AC trace**: AZ-809 AC-1 (rule 9, cross-field invariant).
## BT-N05: Geofence with Inverted Corners — superseded by AZ-809
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat (NW south-of SE).
**Expected**: HTTP 400 + `ValidationProblemDetails`. Post-AZ-809 the failure surfaces at `errors["geofences.polygons[0].northWest"]` with message "\`northWest.lat\` must be greater than \`southEast.lat\` (NW is north-of SE)".
**Pass criterion**: HTTP 400; named error key matches the wire path; message is the cross-field invariant.
**AC trace**: AZ-809 AC-1 (rule 9).
---
## 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?lat=47.461747&lon=37.647063&zoom=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.
---
## Cycle 7 — AZ-794 / AZ-795 / AZ-796 Strict Validation + z/x/y Rename
Cycle 7 hardens `POST /api/satellite/tiles/inventory` by combining (a) the OSM-convention rename of body fields from `tileZoom/tileX/tileY` to `z/x/y` (AZ-794), (b) the shared input-validation infrastructure (AZ-795 — FluentValidation + `ValidationEndpointFilter<T>` + `GlobalExceptionHandler` + `JsonSerializerOptions.UnmappedMemberHandling.Disallow`), and (c) the inventory endpoint's nine concrete validation rules (AZ-796). The cycle's wire-shape contract is `tile-inventory.md` v2.0.0; the failure-response contract is `error-shape.md` v1.0.0.
The cycle introduces no new HTTP routes. Functional positive coverage is unchanged from cycle 6 — `TileInventoryTests.OrderingAndPresentAbsentShaping_AC1` and the rest of the AZ-505 suite continue to assert ordering, present/absent shaping, leaflet selection, HTTP/2, and the perf budget against the post-rename body shape. The new tests below cover the strict-rejection behaviour that pre-cycle-7 silently coerced.
## BT-27: Inventory Endpoint — Nine Validation Rules with RFC 7807 ProblemDetails
**Trigger**: A family of `POST /api/satellite/tiles/inventory` calls, each violating exactly ONE validation rule from AZ-796 §"Required validations (9 rules)". One additional sub-case asserts the legacy `tileZoom/tileX/tileY` field names are now rejected (intersection of AZ-794 + AZ-796).
**Precondition**: API up; valid JWT attached on every sub-case. `error-shape.md` v1.0.0 + `tile-inventory.md` v2.0.0 frozen.
**Expected**: HTTP 400 with `Content-Type: application/problem+json` for every sub-case. The body conforms to the validation-failure shape from `error-shape.md` v1.0.0 (Inv-1 .. Inv-7); the `errors` map names the offending field path using the request body's camelCase. Sub-cases:
| # | Rule | Trigger excerpt | Expected `errors` key | Test method |
|---|------|-----------------|-----------------------|-------------|
| 1 | Body present | empty body (zero bytes, `Content-Type: application/json`) | (no `errors` map — framework-level ProblemDetails) | `EmptyBody_Returns400` |
| 2a | XOR of `tiles` / `locationHashes` | `{}` (neither populated) | `$` | `NeitherPopulated_Returns400` |
| 2b | XOR (both populated) | `{"tiles":[…],"locationHashes":[…]}` | `$` | `BothPopulated_Returns400` |
| 3 | `tiles` non-empty | `{"tiles":[]}` | `$` (treated as not-populated by XOR) | `EmptyTilesArray_Returns400` |
| 4 | `tiles``MaxEntriesPerRequest` (5000) | 5001-entry `tiles` array | `tiles` | `TilesOverCap_Returns400` |
| 5a | Required `z` on each entry | `{"tiles":[{"x":1,"y":1}]}` | path mentioning `z` | `MissingZ_Returns400WithFieldPath` |
| 5b | Required `x` / `y` on each entry | `{"tiles":[{"z":18}]}` | (validator + JsonRequired report missing axes) | `MissingXAndY_Returns400` |
| 6a | Non-negative axis | `{"tiles":[{"z":18,"x":-1,"y":0}]}` | `tiles[0].x` | `NegativeAxis_Returns400` |
| 6b | Integer type | `{"tiles":[{"z":"eighteen","x":1,"y":1}]}` | path mentioning the axis | `TypeMismatch_Returns400` |
| 7 | `z` in slippy-map range 0..22 | `{"tiles":[{"z":30,"x":0,"y":0}]}` | `tiles[0].z`; message mentions "between 0 and 22" | `ZoomOutOfRange_Returns400WithFieldPath` |
| 8a | `x` < 2^z | `{"tiles":[{"z":2,"x":4,"y":0}]}` | `tiles[0].x` | `XBeyondZoomBounds_Returns400` |
| 8b | `y` < 2^z | `{"tiles":[{"z":0,"x":0,"y":1}]}` | `tiles[0].y` | `YBeyondZoomBounds_Returns400` |
| 9a | Unknown root field | `{"unknownField":42,"tiles":[…]}` | path mentioning `unknownField` | `UnknownRootField_Returns400` |
| 9b | Unknown nested field on tile entry | `{"tiles":[{"z":18,"x":1,"y":1,"foo":42}]}` | path mentioning `foo` | `UnknownNestedField_Returns400` |
| 9c | Legacy v1 names (`tileZoom/tileX/tileY`) | exact AZ-777 Phase 1 reproduction body | path mentioning `tileZoom` | `OldV1FieldName_Returns400` (cross-listed under AZ-794) |
| pos | Happy path with z/x/y | `{"tiles":[{"z":18,"x":1,"y":1}]}` | HTTP 200 — no body shape change vs AZ-505 baseline | `HappyPath_Returns200` |
**Pass criterion**: Every failure sub-case returns HTTP 400 with the expected `errors` key OR an equivalent RFC 7807 ProblemDetails for the empty-body case; the happy path returns HTTP 200. No sub-case leaks server-internal state (DB strings, secrets, stack traces) per `error-shape.md` Inv-5.
**AC trace**: AZ-796 AC-1 (all 9 rules + ProblemDetails shape), AC-2 (happy path); AZ-794 AC-1 (positive z/x/y acceptance — sub-case `pos`), AZ-794 AC-2 (sub-case `9c` proves the old names produce a structured 400, eliminating the silent-coerce-to-0 footgun); AZ-795 (epic-level — every sub-case exercises the shared `ValidationEndpointFilter` + `GlobalExceptionHandler` + `UnmappedMemberHandling.Disallow` infra).
**Notes**: The 9 rules split across two enforcement layers — rules 5/6/9 are enforced by the deserializer (`JsonRequired` + `UnmappedMemberHandling.Disallow` + native JSON type validation, see AZ-795 shared infra) and surface as `BadHttpRequestException``GlobalExceptionHandler.JsonException` branch; rules 2/3/4/7/8 are enforced by `InventoryRequestValidator` (FluentValidation) via `ValidationEndpointFilter<TileInventoryRequest>`. Both paths produce identically-shaped `ValidationProblemDetails` bodies (`error-shape.md` v1.0.0 invariant).