mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:51:13 +00:00
Compare commits
8 Commits
8c13cd4f30
...
b763da3f24
| Author | SHA1 | Date | |
|---|---|---|---|
| b763da3f24 | |||
| bbe87835a9 | |||
| 490902c80a | |||
| 5e056b2334 | |||
| 34ee1e0b83 | |||
| fcd494f67e | |||
| 0810a89ef1 | |||
| 06d160daf0 |
@@ -73,7 +73,7 @@ The service follows a layered architecture:
|
||||
### Download Single Tile
|
||||
|
||||
```http
|
||||
GET /api/satellite/tiles/latlon?Latitude={lat}&Longitude={lon}&ZoomLevel={zoom}
|
||||
GET /api/satellite/tiles/latlon?lat={lat}&lon={lon}&zoom={zoom}
|
||||
```
|
||||
|
||||
Downloads a single tile at specified coordinates and zoom level.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace SatelliteProvider.Api.DTOs;
|
||||
|
||||
// AZ-811: query-string record for GET /api/satellite/tiles/latlon.
|
||||
// Bound via `[AsParameters]` so each property maps to one query parameter.
|
||||
// `[FromQuery(Name = "...")]` pins the wire name explicitly — case-sensitive
|
||||
// match against `?lat=&lon=&zoom=`, matching the OSM convention shared with
|
||||
// the rest of the satellite-provider API (`{z, x, y}` for inventory,
|
||||
// `{lat, lon}` for region and route DTOs).
|
||||
//
|
||||
// **Why nullable types**: minimal-API parameter binding throws
|
||||
// BadHttpRequestException for missing-required non-nullable query params
|
||||
// BEFORE endpoint filters run. That short-circuit produces a plain
|
||||
// ProblemDetails via GlobalExceptionHandler — no `errors{}` envelope, no
|
||||
// per-field key. Per AZ-811 ACs 1 & 4 every missing/unknown param must
|
||||
// surface as `errors.<paramName>` in ValidationProblemDetails. Nullable
|
||||
// types let binding always succeed, so:
|
||||
// 1. RejectUnknownQueryParamsEndpointFilter handles unknown keys
|
||||
// (e.g. legacy `?Latitude=`, hostile `?debug=1`).
|
||||
// 2. GetTileByLatLonQueryValidator handles `null` (missing) plus range.
|
||||
// Validator guarantees non-null by the time the handler dereferences.
|
||||
public sealed record GetTileByLatLonQuery(
|
||||
[property: FromQuery(Name = "lat")] double? Lat,
|
||||
[property: FromQuery(Name = "lon")] double? Lon,
|
||||
[property: FromQuery(Name = "zoom")] int? Zoom);
|
||||
@@ -122,6 +122,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
|
||||
// AZ-810: explicit registration so `.AddEndpointFilter<UavUploadValidationFilter>()`
|
||||
// on the UAV upload endpoint resolves the filter with its `IValidator<…>` + JSON
|
||||
// options constructor deps. Transient so each request gets a fresh instance.
|
||||
builder.Services.AddTransient<UavUploadValidationFilter>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
@@ -206,6 +211,10 @@ app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
|
||||
|
||||
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" }))
|
||||
.WithValidation<GetTileByLatLonQuery>()
|
||||
.Produces<DownloadTileResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
|
||||
|
||||
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
||||
@@ -227,6 +236,7 @@ app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
|
||||
app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
.RequireAuthorization(SatellitePermissions.UavUploadPolicy)
|
||||
.AddEndpointFilter<UavUploadValidationFilter>()
|
||||
.Accepts<UavTileBatchUploadRequest>("multipart/form-data")
|
||||
.Produces<UavTileBatchUploadResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
@@ -239,6 +249,10 @@ app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
|
||||
app.MapPost("/api/satellite/request", RequestRegion)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<RequestRegionRequest>()
|
||||
.Accepts<RequestRegionRequest>("application/json")
|
||||
.Produces<RegionStatusResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Request tiles for a region",
|
||||
@@ -251,6 +265,10 @@ app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
||||
|
||||
app.MapPost("/api/satellite/route", CreateRoute)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<CreateRouteRequest>()
|
||||
.Accepts<CreateRouteRequest>("application/json")
|
||||
.Produces<RouteResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Create a route with intermediate points",
|
||||
@@ -271,9 +289,11 @@ async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITil
|
||||
return Results.Bytes(tile.Bytes, tile.ContentType);
|
||||
}
|
||||
|
||||
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService)
|
||||
async Task<IResult> GetTileByLatLon([AsParameters] GetTileByLatLonQuery query, HttpContext httpContext, ITileService tileService)
|
||||
{
|
||||
var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel, httpContext.RequestAborted);
|
||||
// AZ-811: GetTileByLatLonQueryValidator guarantees lat/lon/zoom are non-null
|
||||
// by the time the handler runs (CascadeMode.Stop + NotNull rules).
|
||||
var tile = await tileService.DownloadAndStoreSingleTileAsync(query.Lat!.Value, query.Lon!.Value, query.Zoom!.Value, httpContext.RequestAborted);
|
||||
|
||||
var response = new DownloadTileResponse
|
||||
{
|
||||
@@ -341,15 +361,10 @@ async Task<IResult> UploadUavTileBatch(
|
||||
|
||||
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService)
|
||||
{
|
||||
if (request.SizeMeters < 100 || request.SizeMeters > 10000)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" });
|
||||
}
|
||||
|
||||
var status = await regionService.RequestRegionAsync(
|
||||
request.Id,
|
||||
request.Latitude,
|
||||
request.Longitude,
|
||||
request.Lat,
|
||||
request.Lon,
|
||||
request.SizeMeters,
|
||||
request.ZoomLevel,
|
||||
request.StitchTiles);
|
||||
|
||||
@@ -11,13 +11,11 @@ public class ParameterDescriptionFilter : IOperationFilter
|
||||
|
||||
var parameterDescriptions = new Dictionary<string, string>
|
||||
{
|
||||
["lat"] = "Latitude coordinate where image was captured",
|
||||
["lon"] = "Longitude coordinate where image was captured",
|
||||
["lat"] = "Latitude coordinate (WGS84, decimal degrees, [-90, 90])",
|
||||
["lon"] = "Longitude coordinate (WGS84, decimal degrees, [-180, 180])",
|
||||
["zoom"] = "Slippy-map zoom level [0, 22] (higher = more detail)",
|
||||
["mgrs"] = "MGRS coordinate string",
|
||||
["squareSideMeters"] = "Square side size in meters",
|
||||
["Latitude"] = "Latitude coordinate of the tile center",
|
||||
["Longitude"] = "Longitude coordinate of the tile center",
|
||||
["ZoomLevel"] = "Zoom level for the tile (higher values = more detail)"
|
||||
["squareSideMeters"] = "Square side size in meters"
|
||||
};
|
||||
|
||||
foreach (var parameter in operation.Parameters)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: FluentValidation rules for POST /api/satellite/route. Wired
|
||||
// through ValidationEndpointFilter<CreateRouteRequest> at endpoint
|
||||
// registration time (.WithValidation<CreateRouteRequest>() in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// _docs/02_document/contracts/api/error-shape.md v1.0.0.
|
||||
//
|
||||
// Required-field detection is handled at the deserializer level via
|
||||
// [JsonRequired] on CreateRouteRequest, RoutePoint, GeofencePolygon, and
|
||||
// GeoPoint, plus JsonSerializerOptions.UnmappedMemberHandling.Disallow
|
||||
// (AZ-795 global). This validator covers post-deserialization business
|
||||
// rules: non-zero id, name + description length, range checks on size /
|
||||
// zoom / points-count, per-point lat/lon ranges (via RoutePointValidator),
|
||||
// per-polygon corner ranges + NW-of-SE invariant (via GeofencePolygonValidator),
|
||||
// and the cross-field createTilesZip-implies-requestMaps rule.
|
||||
public sealed class CreateRouteRequestValidator : AbstractValidator<CreateRouteRequest>
|
||||
{
|
||||
private const double MinRegionSizeMeters = 100.0;
|
||||
private const double MaxRegionSizeMeters = 10000.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
private const int MinPoints = 2;
|
||||
private const int MaxPoints = 500;
|
||||
private const int MaxNameLength = 200;
|
||||
private const int MaxDescriptionLength = 1000;
|
||||
|
||||
public CreateRouteRequestValidator()
|
||||
{
|
||||
RuleFor(req => req.Id)
|
||||
.NotEmpty()
|
||||
.WithMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
|
||||
RuleFor(req => req.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("`name` is required and must not be empty or whitespace.")
|
||||
.MaximumLength(MaxNameLength)
|
||||
.WithMessage($"`name` must be at most {MaxNameLength} characters.");
|
||||
|
||||
RuleFor(req => req.Description)
|
||||
.MaximumLength(MaxDescriptionLength)
|
||||
.When(req => req.Description is not null)
|
||||
.WithMessage($"`description` must be at most {MaxDescriptionLength} characters.");
|
||||
|
||||
RuleFor(req => req.RegionSizeMeters)
|
||||
.InclusiveBetween(MinRegionSizeMeters, MaxRegionSizeMeters)
|
||||
.WithMessage($"`regionSizeMeters` must be between {MinRegionSizeMeters} and {MaxRegionSizeMeters} meters.");
|
||||
|
||||
RuleFor(req => req.ZoomLevel)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
|
||||
RuleFor(req => req.Points)
|
||||
.NotNull().WithMessage("`points` is required.")
|
||||
.Must(p => p is null || p.Count >= MinPoints)
|
||||
.WithMessage($"`points` must contain at least {MinPoints} entries.")
|
||||
.Must(p => p is null || p.Count <= MaxPoints)
|
||||
.WithMessage($"`points` must contain at most {MaxPoints} entries.");
|
||||
|
||||
RuleForEach(req => req.Points)
|
||||
.SetValidator(new RoutePointValidator());
|
||||
|
||||
// Geofences are optional; per-polygon rules apply only when present.
|
||||
// FluentValidation's default property-name policy drops the parent
|
||||
// chain on deep expressions like `req.Geofences!.Polygons` — it emits
|
||||
// only the leaf `polygons`. We OverridePropertyName explicitly so the
|
||||
// wire-format error keys match the JSON path callers actually post:
|
||||
// `errors["geofences.polygons"]` and `errors["geofences.polygons[i].…"]`.
|
||||
When(req => req.Geofences is not null, () =>
|
||||
{
|
||||
RuleFor(req => req.Geofences!.Polygons)
|
||||
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
|
||||
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
|
||||
.OverridePropertyName("geofences.polygons");
|
||||
|
||||
RuleForEach(req => req.Geofences!.Polygons)
|
||||
.SetValidator(new GeofencePolygonValidator())
|
||||
.OverridePropertyName("geofences.polygons");
|
||||
});
|
||||
|
||||
// Cross-field invariant: cannot zip what wasn't downloaded.
|
||||
RuleFor(req => req)
|
||||
.Must(req => !(req.CreateTilesZip && !req.RequestMaps))
|
||||
.WithName("createTilesZip")
|
||||
.WithMessage("`createTilesZip` requires `requestMaps` to be true (can't zip what wasn't downloaded).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: per-polygon validator invoked via RuleForEach on the parent
|
||||
// CreateRouteRequest (guarded by When(geofences != null) at the parent).
|
||||
// Enforces both corner-point shape and the "NW is north-of and west-of SE"
|
||||
// invariant.
|
||||
//
|
||||
// Error path: errors keys land at `geofences.polygons[i].northWest.lat` etc.
|
||||
public sealed class GeofencePolygonValidator : AbstractValidator<GeofencePolygon>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
|
||||
public GeofencePolygonValidator()
|
||||
{
|
||||
// Both corners must be present. Without them no useful range/cross-field
|
||||
// check can run, so short-circuit via .Cascade(CascadeMode.Stop).
|
||||
RuleFor(p => p.NorthWest)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`northWest` corner is required.")
|
||||
.SetValidator(new GeoCornerValidator("northWest")!);
|
||||
|
||||
RuleFor(p => p.SouthEast)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`southEast` corner is required.")
|
||||
.SetValidator(new GeoCornerValidator("southEast")!);
|
||||
|
||||
// Cross-field invariant: NW must be genuinely north-of (lat greater)
|
||||
// AND west-of (lon smaller) SE. Only runs when both corners survived
|
||||
// the NotNull check above; FluentValidation skips the rule if either
|
||||
// is null (.When(...) guard below).
|
||||
RuleFor(p => p)
|
||||
.Must(HaveNorthWestActuallyNorthOfSouthEast)
|
||||
.When(p => p.NorthWest is not null && p.SouthEast is not null)
|
||||
.WithName("northWest")
|
||||
.WithMessage("`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE).");
|
||||
|
||||
RuleFor(p => p)
|
||||
.Must(HaveNorthWestActuallyWestOfSouthEast)
|
||||
.When(p => p.NorthWest is not null && p.SouthEast is not null)
|
||||
.WithName("northWest")
|
||||
.WithMessage("`northWest.lon` must be less than `southEast.lon` (NW is west-of SE).");
|
||||
}
|
||||
|
||||
private static bool HaveNorthWestActuallyNorthOfSouthEast(GeofencePolygon polygon)
|
||||
=> polygon.NorthWest!.Lat > polygon.SouthEast!.Lat;
|
||||
|
||||
private static bool HaveNorthWestActuallyWestOfSouthEast(GeofencePolygon polygon)
|
||||
=> polygon.NorthWest!.Lon < polygon.SouthEast!.Lon;
|
||||
|
||||
// Inner per-corner validator. Kept private to this file because the
|
||||
// polygon corners are the only consumer; if a sibling endpoint needs
|
||||
// point-shape validation, promote and rename.
|
||||
private sealed class GeoCornerValidator : AbstractValidator<GeoPoint>
|
||||
{
|
||||
public GeoCornerValidator(string cornerLabel)
|
||||
{
|
||||
RuleFor(g => g.Lat)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`{cornerLabel}.lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(g => g.Lon)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`{cornerLabel}.lon` must be between {MinLon} and {MaxLon}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Api.DTOs;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-811: FluentValidation rules for the query-string surface of
|
||||
// GET /api/satellite/tiles/latlon. Wired through
|
||||
// ValidationEndpointFilter<GetTileByLatLonQuery> at endpoint registration
|
||||
// time (.WithValidation<GetTileByLatLonQuery>() in Program.cs).
|
||||
//
|
||||
// Each rule maps 1:1 to a query parameter; errors[] keys are camelCase per
|
||||
// GlobalValidatorConfig (matching the wire-format param names `lat`, `lon`,
|
||||
// `zoom`). Required-field detection is `NotNull()` on the nullable-bound
|
||||
// DTO (see GetTileByLatLonQuery for why properties are nullable). Each rule
|
||||
// uses CascadeMode.Stop so a missing param surfaces ONLY as
|
||||
// "`lat` is required" — not also "`lat` must be between -90 and 90" with a
|
||||
// null value. Unknown query parameters are caught upstream by
|
||||
// RejectUnknownQueryParamsEndpointFilter.
|
||||
public sealed class GetTileByLatLonQueryValidator : AbstractValidator<GetTileByLatLonQuery>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public GetTileByLatLonQueryValidator()
|
||||
{
|
||||
RuleFor(q => q.Lat)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`lat` is required.")
|
||||
.InclusiveBetween(MinLat, MaxLat).WithMessage($"`lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(q => q.Lon)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`lon` is required.")
|
||||
.InclusiveBetween(MinLon, MaxLon).WithMessage($"`lon` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(q => q.Zoom)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`zoom` is required.")
|
||||
.InclusiveBetween(MinZoom, MaxZoom).WithMessage($"`zoom` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-808: FluentValidation rules for POST /api/satellite/request.
|
||||
// Wired through ValidationEndpointFilter<RequestRegionRequest> at endpoint
|
||||
// registration time (.WithValidation<RequestRegionRequest>() in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// _docs/02_document/contracts/api/error-shape.md v1.0.0.
|
||||
//
|
||||
// Required-field detection is handled at the deserializer level via
|
||||
// [JsonRequired] on RequestRegionRequest properties plus
|
||||
// JsonSerializerOptions.UnmappedMemberHandling.Disallow (AZ-795). This
|
||||
// validator covers the post-deserialization business rules: non-zero Id,
|
||||
// lat/lon/sizeMeters/zoomLevel range constraints.
|
||||
public sealed class RegionRequestValidator : AbstractValidator<RequestRegionRequest>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const double MinSizeMeters = 100.0;
|
||||
private const double MaxSizeMeters = 10000.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public RegionRequestValidator()
|
||||
{
|
||||
RuleFor(req => req.Id)
|
||||
.NotEmpty()
|
||||
.WithMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
|
||||
RuleFor(req => req.Lat)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(req => req.Lon)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`lon` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(req => req.SizeMeters)
|
||||
.InclusiveBetween(MinSizeMeters, MaxSizeMeters)
|
||||
.WithMessage($"`sizeMeters` must be between {MinSizeMeters} and {MaxSizeMeters} meters.");
|
||||
|
||||
RuleFor(req => req.ZoomLevel)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-811: endpoint filter that rejects any query-string parameter outside an
|
||||
// allowed-set. ASP.NET model binding silently ignores unknown query params,
|
||||
// which means typos (e.g. `?latitude=` after AZ-812's rename to `lat`) bind
|
||||
// to the default value (0.0) and may produce a misleading 200 or a confusing
|
||||
// out-of-range 400 from the value-validator. This filter catches the typo at
|
||||
// the envelope level and returns a structured RFC 7807 ValidationProblemDetails
|
||||
// with errors[<paramName>] = "Unknown query parameter ...", matching the
|
||||
// shape produced by ValidationEndpointFilter<T> + GlobalExceptionHandler.
|
||||
//
|
||||
// Apply BEFORE ValidationEndpointFilter<T> so unknown-param errors precede
|
||||
// range checks against the bound default value.
|
||||
public sealed class RejectUnknownQueryParamsEndpointFilter : IEndpointFilter
|
||||
{
|
||||
private readonly HashSet<string> _allowedKeys;
|
||||
|
||||
public RejectUnknownQueryParamsEndpointFilter(IEnumerable<string> allowedKeys)
|
||||
{
|
||||
_allowedKeys = new HashSet<string>(allowedKeys, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var query = context.HttpContext.Request.Query;
|
||||
var unknown = query.Keys.Where(k => !_allowedKeys.Contains(k)).ToList();
|
||||
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
var errors = unknown.ToDictionary(
|
||||
k => k,
|
||||
k => new[]
|
||||
{
|
||||
$"Unknown query parameter `{k}`. Allowed: {string.Join(", ", _allowedKeys.Select(a => $"`{a}`"))}."
|
||||
});
|
||||
|
||||
return Results.ValidationProblem(errors);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: per-point validator invoked via RuleForEach on the parent
|
||||
// CreateRouteRequest. Each route waypoint must declare a valid WGS84
|
||||
// coordinate; the parent validator checks min/max count of the points
|
||||
// collection separately.
|
||||
//
|
||||
// Error path: errors keys land at `points[i].lat` / `points[i].lon` per
|
||||
// FluentValidation's default child-property naming + GlobalValidatorConfig
|
||||
// camelCase normalization (matches the wire format set by
|
||||
// [JsonPropertyName("lat"|"lon")] on RoutePoint).
|
||||
public sealed class RoutePointValidator : AbstractValidator<RoutePoint>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
|
||||
public RoutePointValidator()
|
||||
{
|
||||
// `RoutePoint.Latitude` is the C# property name but the wire name is
|
||||
// `lat` via [JsonPropertyName]. OverridePropertyName chains AFTER the
|
||||
// first concrete rule (which provides the `TProperty` for the generic
|
||||
// extension) and aligns the FluentValidation error key with the wire
|
||||
// format — callers see `errors["points[i].lat"]` matching what they
|
||||
// posted rather than the camelCased C# name `latitude`.
|
||||
RuleFor(p => p.Latitude)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`lat` must be between {MinLat} and {MaxLat}.")
|
||||
.OverridePropertyName("lat");
|
||||
|
||||
RuleFor(p => p.Longitude)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`lon` must be between {MinLon} and {MaxLon}.")
|
||||
.OverridePropertyName("lon");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: root validator for the UAV upload metadata envelope. Runs from
|
||||
// inside the custom `UavUploadValidationFilter` (the endpoint takes a
|
||||
// multipart form, so the standard `WithValidation<T>()` JSON-body filter
|
||||
// doesn't apply). Error keys come out as `errors.items[…]` from this
|
||||
// validator and are prefixed with `metadata.` by the filter, producing
|
||||
// `errors.metadata.items[…]` in the final ValidationProblemDetails per
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
public sealed class UavTileBatchMetadataPayloadValidator : AbstractValidator<UavTileBatchMetadataPayload>
|
||||
{
|
||||
public UavTileBatchMetadataPayloadValidator(
|
||||
IOptions<UavQualityConfig> qualityConfig,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(qualityConfig);
|
||||
var maxBatchSize = qualityConfig.Value.MaxBatchSize;
|
||||
|
||||
RuleFor(p => p.Items)
|
||||
.NotNull().WithMessage("`items` is required.")
|
||||
.NotEmpty().WithMessage("`items` must contain at least one entry.")
|
||||
.Must(items => items is null || items.Count <= maxBatchSize)
|
||||
.WithMessage($"`items` must contain at most {maxBatchSize} entries.");
|
||||
|
||||
RuleForEach(p => p.Items)
|
||||
.SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: per-item metadata validator for the UAV upload endpoint. Runs as
|
||||
// a `RuleForEach.SetValidator(...)` chain child of `UavTileBatchMetadataPayloadValidator`,
|
||||
// so error keys come out as `errors.metadata.items[i].latitude`, `…tileZoom`,
|
||||
// `…capturedAt`, etc. once the `UavUploadValidationFilter` prefixes the result.
|
||||
//
|
||||
// CapturedAt freshness (rule 11) is the same window that
|
||||
// `IUavTileQualityGate.Validate` enforces; running the same check at the API
|
||||
// boundary lets us short-circuit before any file bytes are inspected. The
|
||||
// gate remains as a defence-in-depth backstop for unit tests of the gate
|
||||
// itself and for the unlikely path of a caller invoking
|
||||
// `IUavTileUploadHandler` directly (bypassing the filter).
|
||||
public sealed class UavTileMetadataValidator : AbstractValidator<UavTileMetadata>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public UavTileMetadataValidator(IOptions<UavQualityConfig> qualityConfig, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(qualityConfig);
|
||||
var cfg = qualityConfig.Value;
|
||||
var tp = timeProvider ?? TimeProvider.System;
|
||||
var maxAgeDays = cfg.MaxAgeDays;
|
||||
var futureSkewSeconds = cfg.CapturedAtFutureSkewSeconds;
|
||||
|
||||
RuleFor(m => m.Latitude)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`latitude` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(m => m.Longitude)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`longitude` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(m => m.TileZoom)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`tileZoom` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
|
||||
RuleFor(m => m.TileSizeMeters)
|
||||
.GreaterThan(0.0)
|
||||
.WithMessage("`tileSizeMeters` must be greater than 0.");
|
||||
|
||||
// Freshness window: capturedAt ∈ [now - MaxAgeDays, now + CapturedAtFutureSkewSeconds].
|
||||
// `Must` lambdas close over `tp` so the comparison fetches fresh
|
||||
// time per call (rule executes at validation time, not constructor
|
||||
// time). Equivalent to AZ-488 Rule 4 in UavTileQualityGate.
|
||||
RuleFor(m => m.CapturedAt)
|
||||
.Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
|
||||
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
|
||||
.Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
|
||||
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
|
||||
|
||||
// `FlightId` is intentionally not validated beyond JSON shape — AZ-503
|
||||
// anonymous-flight semantics require null/missing to be a valid case.
|
||||
// System.Text.Json already rejects malformed UUID strings at the
|
||||
// deserializer with `JsonException` → 400 via GlobalExceptionHandler.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: endpoint filter for `POST /api/satellite/upload`. The endpoint is
|
||||
// `multipart/form-data`, not a plain JSON body, so the standard
|
||||
// `WithValidation<T>()` filter (which expects an `[FromBody]` argument
|
||||
// already deserialized by the binder) cannot be used. This filter reads
|
||||
// the multipart `metadata` form field, deserializes it with the strict
|
||||
// global `JsonSerializerOptions` (which includes
|
||||
// `UnmappedMemberHandling.Disallow` from AZ-795), runs the FluentValidation
|
||||
// rules on `UavTileBatchMetadataPayload`, and adds the cross-field
|
||||
// alignment check (`metadata.items.Count == files.Count`).
|
||||
//
|
||||
// Failures are returned as RFC 7807 `ValidationProblemDetails` matching
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0; error-map keys
|
||||
// are prefixed with `metadata.` so paths like `items[0].latitude` from
|
||||
// the per-item validator surface to the caller as
|
||||
// `errors["metadata.items[0].latitude"]`.
|
||||
//
|
||||
// The downstream `IUavTileUploadHandler` retains its own envelope checks
|
||||
// as a defence-in-depth backstop (also covers callers invoking the
|
||||
// handler directly in unit tests). When the filter has already validated,
|
||||
// the handler's checks are no-ops by construction.
|
||||
public sealed class UavUploadValidationFilter : IEndpointFilter
|
||||
{
|
||||
private const string MetadataKeyPrefix = "metadata.";
|
||||
private const string MetadataField = "metadata";
|
||||
private const string FilesField = "files";
|
||||
|
||||
private readonly IValidator<UavTileBatchMetadataPayload> _validator;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UavUploadValidationFilter(
|
||||
IValidator<UavTileBatchMetadataPayload> validator,
|
||||
IOptions<JsonOptions> jsonOptions)
|
||||
{
|
||||
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
|
||||
ArgumentNullException.ThrowIfNull(jsonOptions);
|
||||
_jsonOptions = jsonOptions.Value.SerializerOptions;
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "Request must be `multipart/form-data`." },
|
||||
});
|
||||
}
|
||||
|
||||
var form = await request.ReadFormAsync(context.HttpContext.RequestAborted);
|
||||
var metadataField = form[MetadataField].ToString();
|
||||
var files = form.Files;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(metadataField))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "`metadata` form field is required." },
|
||||
});
|
||||
}
|
||||
|
||||
UavTileBatchMetadataPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(metadataField, _jsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// System.Text.Json with UnmappedMemberHandling.Disallow + [JsonRequired]
|
||||
// covers: unknown root/nested fields, missing required fields, type
|
||||
// mismatches. Surface uniformly as `errors.metadata`.
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
|
||||
});
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "`metadata` must be a non-null JSON object." },
|
||||
});
|
||||
}
|
||||
|
||||
var result = await _validator.ValidateAsync(payload, context.HttpContext.RequestAborted);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var prefixed = new Dictionary<string, string[]>(StringComparer.Ordinal);
|
||||
foreach (var group in result.ToDictionary())
|
||||
{
|
||||
prefixed[MetadataKeyPrefix + group.Key] = group.Value;
|
||||
}
|
||||
return Results.ValidationProblem(prefixed);
|
||||
}
|
||||
|
||||
if (payload.Items.Count != files.Count)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataKeyPrefix + "items"] = new[]
|
||||
{
|
||||
$"`metadata.items` has {payload.Items.Count} entries but `files` has {files.Count}.",
|
||||
},
|
||||
[FilesField] = new[]
|
||||
{
|
||||
$"`files` has {files.Count} entries but `metadata.items` has {payload.Items.Count}.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,35 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class CreateRouteRequest
|
||||
{
|
||||
// AZ-809: [JsonRequired] enforces presence at the deserializer; range and
|
||||
// shape checks live in `SatelliteProvider.Api/Validators/CreateRouteRequestValidator`.
|
||||
// Description and Geofences remain optional. The legacy in-service
|
||||
// `RouteValidator` is left in place as defense-in-depth for direct
|
||||
// service-layer callers (e.g. unit tests).
|
||||
[JsonRequired]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public double RegionSizeMeters { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int ZoomLevel { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public List<RoutePoint> Points { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("geofences")]
|
||||
public Geofences? Geofences { get; set; }
|
||||
|
||||
public bool RequestMaps { get; set; } = false;
|
||||
public bool CreateTilesZip { get; set; } = false;
|
||||
[JsonRequired]
|
||||
public bool RequestMaps { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public bool CreateTilesZip { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ public class GeoPoint
|
||||
{
|
||||
const double PRECISION_TOLERANCE = 0.00005;
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class GeofencePolygon
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("northWest")]
|
||||
public GeoPoint? NorthWest { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("southEast")]
|
||||
public GeoPoint? SouthEast { get; set; }
|
||||
}
|
||||
|
||||
public class Geofences
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("polygons")]
|
||||
public List<GeofencePolygon> Polygons { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
// AZ-812 (cycle 8): wire-format renamed Latitude/Longitude → Lat/Lon (OSM
|
||||
// convention) and added [JsonPropertyName("lat"/"lon")] so the wire is
|
||||
// unambiguous under JsonSerializerOptions.UnmappedMemberHandling.Disallow
|
||||
// (AZ-795 cycle 7).
|
||||
//
|
||||
// AZ-808 (cycle 8): switched [Required] → [JsonRequired] on every property.
|
||||
// [Required] is DataAnnotations and is NOT enforced by System.Text.Json — the
|
||||
// 2026-05-22 black-box probe confirmed it: omitting `id` returned HTTP 200
|
||||
// with id=Guid.Empty (silent coercion). [JsonRequired] is enforced by the
|
||||
// STJ deserializer and fails with BadHttpRequestException(JsonException),
|
||||
// which the GlobalExceptionHandler converts to RFC 7807 ValidationProblemDetails.
|
||||
// Removed the in-property defaults (= 18 for ZoomLevel, = false for StitchTiles)
|
||||
// because [JsonRequired] forces the caller to declare intent.
|
||||
public record RequestRegionRequest
|
||||
{
|
||||
[Required]
|
||||
[JsonRequired]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public double Latitude { get; set; }
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[Required]
|
||||
public double Longitude { get; set; }
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonRequired]
|
||||
public double SizeMeters { get; set; }
|
||||
|
||||
[Required]
|
||||
public int ZoomLevel { get; set; } = 18;
|
||||
[JsonRequired]
|
||||
public int ZoomLevel { get; set; }
|
||||
|
||||
public bool StitchTiles { get; set; } = false;
|
||||
[JsonRequired]
|
||||
public bool StitchTiles { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class RoutePoint
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Latitude { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Longitude { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each
|
||||
@@ -9,17 +11,28 @@ namespace SatelliteProvider.Common.DTO;
|
||||
// to per-flight on-disk paths (./tiles/uav/{flight_id}/{z}/{x}/{y}.jpg). When
|
||||
// absent, the row is treated as flight-anonymous and the UPSERT collapses to
|
||||
// the AZ-484 "single row per (cell, source)" semantics via COALESCE-to-zero.
|
||||
//
|
||||
// AZ-810 (cycle 8) added [JsonRequired] to every non-optional axis so the
|
||||
// deserializer rejects partial payloads with HTTP 400 + ValidationProblemDetails
|
||||
// via GlobalExceptionHandler BEFORE the FluentValidation + IUavTileQualityGate
|
||||
// layers run. FlightId stays optional per AZ-503 anonymous-flight semantics.
|
||||
public record UavTileMetadata
|
||||
{
|
||||
[JsonRequired]
|
||||
public double Latitude { get; init; }
|
||||
[JsonRequired]
|
||||
public double Longitude { get; init; }
|
||||
[JsonRequired]
|
||||
public int TileZoom { get; init; }
|
||||
[JsonRequired]
|
||||
public double TileSizeMeters { get; init; }
|
||||
[JsonRequired]
|
||||
public DateTime CapturedAt { get; init; }
|
||||
public Guid? FlightId { get; init; }
|
||||
}
|
||||
|
||||
public record UavTileBatchMetadataPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public List<UavTileMetadata> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-809: end-to-end coverage for POST /api/satellite/route strict input
|
||||
// validation. Each test exercises one rule from the AZ-809 validator triplet
|
||||
// (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator)
|
||||
// and asserts the response body conforms to the RFC 7807
|
||||
// ValidationProblemDetails contract in `_docs/02_document/contracts/api/error-shape.md`
|
||||
// v1.0.0. Required-field detection is enforced at the deserializer layer via
|
||||
// [JsonRequired] + UnmappedMemberHandling.Disallow (AZ-795).
|
||||
//
|
||||
// The route-creation happy path is intentionally `requestMaps=false` here to
|
||||
// keep this suite fast; the existing RouteCreationTests.cs exercises the
|
||||
// `requestMaps=true` flow (with background F5 processing).
|
||||
public static class CreateRouteValidationTests
|
||||
{
|
||||
private const string RoutePath = "/api/satellite/route";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/route strict validation (AZ-809)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Rule 1: body present
|
||||
await EmptyBody_Returns400(httpClient);
|
||||
|
||||
// Rule 2: id required, non-zero Guid (probe-confirmed gap)
|
||||
await MissingId_Returns400(httpClient);
|
||||
await ZeroGuidId_Returns400(httpClient);
|
||||
|
||||
// Rule 3: name required, length [1, 200]
|
||||
await EmptyName_Returns400(httpClient);
|
||||
|
||||
// Rule 5: regionSizeMeters required, [100, 10000]
|
||||
await RegionSizeOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 6: zoomLevel required, [0, 22]
|
||||
await ZoomLevelOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 7: points required, [2, 500]
|
||||
await PointsTooFew_Returns400(httpClient);
|
||||
|
||||
// Rule 8: per-point lat/lon ranges
|
||||
await PointLatOutOfRange_Returns400(httpClient);
|
||||
await PointLonOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 9: geofence corners + NW-of-SE invariant
|
||||
await GeofenceNwLatNotGreaterThanSeLat_Returns400(httpClient);
|
||||
|
||||
// Rule 10/11: requestMaps + createTilesZip required
|
||||
await MissingRequestMaps_Returns400(httpClient);
|
||||
|
||||
// Rule 12: cross-field createTilesZip implies requestMaps
|
||||
await CreateTilesZipWithoutRequestMaps_Returns400(httpClient);
|
||||
|
||||
// Rule 13: unknown root field rejected
|
||||
await UnknownRootField_Returns400(httpClient);
|
||||
|
||||
// Rule 14: type mismatch (per-point lat)
|
||||
await PointLatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Create-route validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 AC-2: well-formed body → HTTP 200 (no background processing — requestMaps=false)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-809 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task EmptyBody_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 1: empty body → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, "");
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-809 rule 1: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Empty body rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MissingId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)");
|
||||
|
||||
// Arrange — same exact pattern as the AZ-808 probe finding.
|
||||
var body = """
|
||||
{
|
||||
"name": "derkachi-flight-1",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing id");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-809 missing id");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)");
|
||||
}
|
||||
|
||||
private static async Task ZeroGuidId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 2: zero-Guid `id` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var body = BuildValidBody(Guid.Empty, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zero-Guid id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zero-Guid id", expectedErrorPath: "id");
|
||||
|
||||
Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]");
|
||||
}
|
||||
|
||||
private static async Task EmptyName_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 3: empty `name` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 empty name");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 empty name", expectedErrorPath: "name");
|
||||
|
||||
Console.WriteLine(" ✓ Empty `name` rejected with errors[\"name\"]");
|
||||
}
|
||||
|
||||
private static async Task RegionSizeOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 5: `regionSizeMeters` out of range (100..10000) → HTTP 400");
|
||||
|
||||
// Arrange — same 1M cap-exceeder as AZ-808.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, regionSize: 1_000_000, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 regionSize out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 regionSize out of range", expectedErrorPath: "regionSizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ `regionSizeMeters=1000000` rejected with errors[\"regionSizeMeters\"]");
|
||||
}
|
||||
|
||||
private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 6: `zoomLevel` out of range (0..22) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, zoom: 30, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zoomLevel out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zoomLevel out of range", expectedErrorPath: "zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]");
|
||||
}
|
||||
|
||||
private static async Task PointsTooFew_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 7: `points` count < 2 → HTTP 400");
|
||||
|
||||
// Arrange — single point.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "single-point-route",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 points too few");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 points too few", expectedErrorPath: "points");
|
||||
|
||||
Console.WriteLine(" ✓ `points` count=1 rejected with errors[\"points\"]");
|
||||
}
|
||||
|
||||
private static async Task PointLatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 8: per-point `lat` out of range → HTTP 400 (errors[points[i].lat])");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "out-of-range-lat",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 91.0, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lat", label: "AZ-809 point lat out of range");
|
||||
|
||||
Console.WriteLine(" ✓ `points[1].lat=91` rejected with errors[\"points[1].lat\"]");
|
||||
}
|
||||
|
||||
private static async Task PointLonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 8: per-point `lon` out of range → HTTP 400 (errors[points[i].lon])");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "out-of-range-lon",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 181.0 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lon out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lon", label: "AZ-809 point lon out of range");
|
||||
|
||||
Console.WriteLine(" ✓ `points[1].lon=181` rejected with errors[\"points[1].lon\"]");
|
||||
}
|
||||
|
||||
private static async Task GeofenceNwLatNotGreaterThanSeLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 9: geofence NW.lat <= SE.lat → HTTP 400 (cross-field invariant)");
|
||||
|
||||
// Arrange — NW.lat == SE.lat → NW not north-of SE.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "inverted-geofence",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{ "northWest": { "lat": 50.05, "lon": 36.05 },
|
||||
"southEast": { "lat": 50.05, "lon": 36.15 } }
|
||||
]
|
||||
},
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 NW lat not > SE lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 NW lat not > SE lat");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "northWest", label: "AZ-809 NW lat not > SE lat");
|
||||
|
||||
Console.WriteLine(" ✓ NW.lat <= SE.lat rejected by cross-field invariant");
|
||||
}
|
||||
|
||||
private static async Task MissingRequestMaps_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 10: missing `requestMaps` → HTTP 400 (no defaulting)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "no-requestMaps",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing requestMaps");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing requestMaps");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "requestMaps", label: "AZ-809 missing requestMaps");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `requestMaps` rejected");
|
||||
}
|
||||
|
||||
private static async Task CreateTilesZipWithoutRequestMaps_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 12: `createTilesZip=true` AND `requestMaps=false` → HTTP 400 (cross-field invariant)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: true);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 createTilesZip without requestMaps");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 createTilesZip without requestMaps", expectedErrorPath: "createTilesZip");
|
||||
|
||||
Console.WriteLine(" ✓ `createTilesZip=true requestMaps=false` rejected by cross-field invariant");
|
||||
}
|
||||
|
||||
private static async Task UnknownRootField_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 13: unknown root field → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "with-unknown-field",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false,
|
||||
"debug": "fingerprint-probe"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 unknown root field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-809 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors mention");
|
||||
}
|
||||
|
||||
private static async Task PointLatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 14: nested type mismatch (`points[0].lat` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "nested-type-mismatch",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": "fifty", "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat type mismatch");
|
||||
|
||||
// Assert — GlobalExceptionHandler converts BadHttpRequestException to
|
||||
// ValidationProblemDetails when the inner JsonException's Path is set.
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ `points[0].lat:\"fifty\"` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static string BuildValidBody(
|
||||
Guid routeId,
|
||||
double regionSize = 1000.0,
|
||||
int zoom = 18,
|
||||
bool requestMaps = false,
|
||||
bool createTilesZip = false)
|
||||
{
|
||||
// Lat/lon picked from gps-denied-onboard AZ-777 Phase 2 probe.
|
||||
return $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "az-809-integration-test",
|
||||
"description": "AZ-809 integration test route",
|
||||
"regionSizeMeters": {{regionSize.ToString(System.Globalization.CultureInfo.InvariantCulture)}},
|
||||
"zoomLevel": {{zoom}},
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": {{(requestMaps ? "true" : "false")}},
|
||||
"createTilesZip": {{(createTilesZip ? "true" : "false")}}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RoutePath, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-811: end-to-end coverage for GET /api/satellite/tiles/latlon strict input
|
||||
// validation. Two enforcement layers:
|
||||
// 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside
|
||||
// {lat, lon, zoom}, catching typos like `?latitude=` that pre-AZ-811
|
||||
// silently bound to 0.
|
||||
// 2. WithValidation<GetTileByLatLonQuery> — range-checks lat, lon, zoom.
|
||||
// Both surface RFC 7807 ValidationProblemDetails per error-shape.md v1.0.0.
|
||||
public static class GetTileByLatLonValidationTests
|
||||
{
|
||||
private const string LatLonPath = "/api/satellite/tiles/latlon";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: GET /api/satellite/tiles/latlon strict validation (AZ-811)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Validator rules (range)
|
||||
await LatOutOfRange_Returns400(httpClient);
|
||||
await LonOutOfRange_Returns400(httpClient);
|
||||
await ZoomOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Validator rules (missing required)
|
||||
await MissingLat_Returns400(httpClient);
|
||||
|
||||
// Envelope rule: unknown query params
|
||||
await UnknownQueryParam_LegacyLatitude_Returns400(httpClient);
|
||||
await UnknownQueryParam_Hostile_Returns400(httpClient);
|
||||
|
||||
// Type mismatch (delegates to GlobalExceptionHandler via model-binding)
|
||||
await LatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ GET lat/lon validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 AC-2: well-formed query → HTTP 200");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18");
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-811 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ {lat,lon,zoom} accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task LatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 1: lat out of range (-90..90) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=91&lon=37.647063&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lat out of range", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ lat=91 rejected with errors[\"lat\"]");
|
||||
}
|
||||
|
||||
private static async Task LonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 2: lon out of range (-180..180) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=181&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lon out of range", expectedErrorPath: "lon");
|
||||
|
||||
Console.WriteLine(" ✓ lon=181 rejected with errors[\"lon\"]");
|
||||
}
|
||||
|
||||
private static async Task ZoomOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 3: zoom out of range (0..22) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=30");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 zoom out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 zoom out of range", expectedErrorPath: "zoom");
|
||||
|
||||
Console.WriteLine(" ✓ zoom=30 rejected with errors[\"zoom\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat");
|
||||
|
||||
// Act — only lon + zoom supplied; the validator's NotNull rule on Lat must
|
||||
// fire (binder produces Lat=null because the DTO is nullable; see
|
||||
// GetTileByLatLonQuery for why).
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lon=37.647063&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 missing lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 missing lat", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ Missing lat rejected with errors[\"lat\"] = `lat` is required");
|
||||
}
|
||||
|
||||
private static async Task UnknownQueryParam_LegacyLatitude_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter)");
|
||||
|
||||
// Act — exact pre-AZ-811 wire format; must now fail explicitly instead
|
||||
// of silently binding to lat=0/lon=0/zoom=0 (typo class).
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 legacy param names");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 legacy param names");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "Latitude", label: "AZ-811 legacy param names");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter");
|
||||
}
|
||||
|
||||
private static async Task UnknownQueryParam_Hostile_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter)");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 hostile params");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 hostile params");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-811 hostile params");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "admin", label: "AZ-811 hostile params");
|
||||
|
||||
Console.WriteLine(" ✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys");
|
||||
}
|
||||
|
||||
private static async Task LatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=fifty&lon=37.647063&zoom=18");
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert — ASP.NET query-param binding produces 400 for type mismatch via
|
||||
// BadHttpRequestException; the exact ProblemDetails shape varies depending
|
||||
// on whether the GlobalExceptionHandler intercepts. Either way the wire
|
||||
// contract is HTTP 400, no body leak.
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-811 type mismatch: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ lat=fifty rejected with HTTP 400");
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ public static class IdempotentPostTests
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = regionId,
|
||||
latitude = 47.4617,
|
||||
longitude = 37.6470,
|
||||
lat = 47.4617,
|
||||
lon = 37.6470,
|
||||
sizeMeters = 200,
|
||||
zoomLevel = 18,
|
||||
stitchTiles = false,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class JwtIntegrationTests
|
||||
{
|
||||
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18";
|
||||
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18";
|
||||
private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000";
|
||||
|
||||
public static async Task RunAll(string apiUrl, string secret)
|
||||
|
||||
@@ -17,8 +17,13 @@ public record DownloadTileResponse
|
||||
public record RequestRegionRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
public double SizeMeters { get; set; }
|
||||
public int ZoomLevel { get; set; }
|
||||
public bool StitchTiles { get; set; } = false;
|
||||
|
||||
@@ -92,6 +92,46 @@ public static class ProblemDetailsAssertions
|
||||
}
|
||||
}
|
||||
|
||||
// AZ-808 cycle 8: promoted from per-test-file private helpers (was
|
||||
// duplicated in TileInventoryValidationTests + RegionFieldRenameTests +
|
||||
// RegionRequestValidationTests) so every validation test points at one
|
||||
// source of truth for "is this field-name or substring mentioned anywhere
|
||||
// in the errors map?".
|
||||
public static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label)
|
||||
{
|
||||
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new Exception($"{label}: expected 'errors' object in ProblemDetails body.");
|
||||
}
|
||||
|
||||
var found = false;
|
||||
foreach (var prop in errorsEl.EnumerateObject())
|
||||
{
|
||||
if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var msg in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name));
|
||||
throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumeratePaths(JsonElement errorsEl)
|
||||
{
|
||||
foreach (var prop in errorsEl.EnumerateObject())
|
||||
|
||||
@@ -103,6 +103,7 @@ class Program
|
||||
|
||||
await JwtIntegrationTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadValidationTests.RunAll(apiUrl, jwtSecret);
|
||||
await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret);
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
@@ -140,6 +141,10 @@ class Program
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await TileInventoryTests.RunAll(httpClient);
|
||||
await TileInventoryValidationTests.RunAll(httpClient);
|
||||
await RegionFieldRenameTests.RunAll(httpClient);
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
@@ -164,6 +169,10 @@ class Program
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await TileInventoryTests.RunAll(httpClient);
|
||||
await TileInventoryValidationTests.RunAll(httpClient);
|
||||
await RegionFieldRenameTests.RunAll(httpClient);
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-812: wire-format rename for POST /api/satellite/request.
|
||||
// `RequestRegionRequest` now uses `lat`/`lon` (OSM convention) on the wire,
|
||||
// replacing the previous verbose `latitude`/`longitude`. The strict-parsing
|
||||
// infrastructure landed by AZ-795 (UnmappedMemberHandling.Disallow +
|
||||
// GlobalExceptionHandler) means the old wire format must now be rejected
|
||||
// explicitly, not silently coerced. AC-4 from the AZ-812 task spec.
|
||||
public static class RegionFieldRenameTests
|
||||
{
|
||||
private const string RegionPath = "/api/satellite/request";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Region endpoint OSM field-name rename (AZ-812)");
|
||||
|
||||
await NewLatLonFormat_Returns200(httpClient);
|
||||
await OldLatitudeLongitudeFormat_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Region field-rename tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task NewLatLonFormat_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-812 AC-4 (positive): new {lat,lon} wire format → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-812 AC-4 positive: expected HTTP 200 for {{lat,lon}} body, got {status}. Body: {responseBody}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ {lat,lon} body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task OldLatitudeLongitudeFormat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-812 AC-4 (negative): legacy {latitude,longitude} wire format → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange — exact pre-AZ-812 wire format; must now fail explicitly instead
|
||||
// of silently mapping to the renamed Lat/Lon properties.
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-812 legacy field names");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-812 legacy field names");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "latitude", label: "AZ-812 legacy field names");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy {latitude,longitude} body rejected with HTTP 400; errors map names the unknown field");
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RegionPath, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-808: end-to-end coverage for the region-request endpoint's strict input
|
||||
// validation. Each test exercises one rule from the validator (FluentValidation
|
||||
// for business rules, JsonSerializerOptions for wire-format rules) and asserts
|
||||
// the response body conforms to the RFC 7807 ValidationProblemDetails contract
|
||||
// in `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// Field names use the post-AZ-812 OSM convention (`lat`/`lon`). The legacy
|
||||
// `latitude`/`longitude` wire format is verified to be rejected by
|
||||
// RegionFieldRenameTests.cs (AZ-812 AC-4).
|
||||
public static class RegionRequestValidationTests
|
||||
{
|
||||
private const string RegionPath = "/api/satellite/request";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Region endpoint strict validation (AZ-808)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Rule 1: body present
|
||||
await EmptyBody_Returns400(httpClient);
|
||||
|
||||
// Rule 2: id required, non-zero Guid
|
||||
await MissingId_Returns400(httpClient);
|
||||
await ZeroGuidId_Returns400(httpClient);
|
||||
|
||||
// Rule 3: lat required, [-90, 90]
|
||||
await MissingLat_Returns400(httpClient);
|
||||
await LatOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 4: lon required, [-180, 180]
|
||||
await MissingLon_Returns400(httpClient);
|
||||
await LonOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 5: sizeMeters required, [100, 10000]
|
||||
await MissingSizeMeters_Returns400(httpClient);
|
||||
await SizeMetersOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 6: zoomLevel required, [0, 22]
|
||||
await MissingZoomLevel_Returns400(httpClient);
|
||||
await ZoomLevelOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 7: stitchTiles required (bool, no default)
|
||||
await MissingStitchTiles_Returns400(httpClient);
|
||||
|
||||
// Rule 9: type mismatch
|
||||
await LatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
// Rule 8 (unknown root fields) is covered by RegionFieldRenameTests (AZ-812 AC-4).
|
||||
|
||||
Console.WriteLine("✓ Region-request validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 AC-2: well-formed request → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-808 AC-2 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task EmptyBody_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 1: empty body → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = "";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-808 rule 1: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Empty body rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MissingId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)");
|
||||
|
||||
// Arrange — the exact 2026-05-22 probe payload that silently coerced to Guid.Empty pre-AZ-808.
|
||||
const string body = "{\"lat\":49.94,\"lon\":36.31,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing id");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-808 missing id");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)");
|
||||
}
|
||||
|
||||
private static async Task ZeroGuidId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 2: zero-Guid `id` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = "{\"id\":\"00000000-0000-0000-0000-000000000000\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zero-Guid id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zero-Guid id", expectedErrorPath: "id");
|
||||
|
||||
Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 3: missing `lat` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lat");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lat", label: "AZ-808 missing lat");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `lat` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 3: `lat` out of range (-90..90) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":91.0,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat out of range", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ `lat=91.0` rejected with errors[\"lat\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLon_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 4: missing `lon` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lon");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lon");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lon", label: "AZ-808 missing lon");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `lon` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 4: `lon` out of range (-180..180) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":181.0,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lon out of range", expectedErrorPath: "lon");
|
||||
|
||||
Console.WriteLine(" ✓ `lon=181.0` rejected with errors[\"lon\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingSizeMeters_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 5: missing `sizeMeters` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing sizeMeters");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing sizeMeters");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "sizeMeters", label: "AZ-808 missing sizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `sizeMeters` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task SizeMetersOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 5: `sizeMeters` out of range (100..10000) → HTTP 400");
|
||||
|
||||
// Arrange — same 1M cap-exceeder used by SEC-03; this validator replaces the old inline check.
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 sizeMeters out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 sizeMeters out of range", expectedErrorPath: "sizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ `sizeMeters=1000000` rejected with errors[\"sizeMeters\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingZoomLevel_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 6: missing `zoomLevel` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing zoomLevel");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing zoomLevel");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "zoomLevel", label: "AZ-808 missing zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `zoomLevel` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 6: `zoomLevel` out of range (0..22) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zoomLevel out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zoomLevel out of range", expectedErrorPath: "zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingStitchTiles_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 7: missing `stitchTiles` → HTTP 400 (no defaulting to false)");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing stitchTiles");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing stitchTiles");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "stitchTiles", label: "AZ-808 missing stitchTiles");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `stitchTiles` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 9: type mismatch (`lat` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat type mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ `lat:\"fifty\"` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RegionPath, content);
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,8 @@ public static class RegionTests
|
||||
var requestRegion = new RequestRegionRequest
|
||||
{
|
||||
Id = regionId,
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
Lat = latitude,
|
||||
Lon = longitude,
|
||||
SizeMeters = sizeMeters,
|
||||
ZoomLevel = zoomLevel,
|
||||
StitchTiles = stitchTiles
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class SecurityTests
|
||||
Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string");
|
||||
|
||||
var injection = "' OR 1=1 --";
|
||||
var url = $"/api/satellite/tiles/latlon?Latitude={Uri.EscapeDataString(injection)}&Longitude=37.647063&ZoomLevel=18";
|
||||
var url = $"/api/satellite/tiles/latlon?lat={Uri.EscapeDataString(injection)}&lon=37.647063&zoom=18";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity)
|
||||
@@ -66,7 +66,7 @@ public static class SecurityTests
|
||||
Console.WriteLine("SEC-03: Oversized region request (sizeMeters beyond allowed cap)");
|
||||
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
var response = await httpClient.PostAsync("/api/satellite/request", content);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
@@ -199,7 +199,7 @@ public static class TileInventoryValidationTests
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 missing z");
|
||||
AssertErrorsContainsMention(problem, expectedMention: "z", label: "AZ-796 missing z");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "z", label: "AZ-796 missing z");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `z` rejected with errors map mentioning the field");
|
||||
}
|
||||
@@ -325,7 +325,7 @@ public static class TileInventoryValidationTests
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown root field");
|
||||
AssertErrorsContainsMention(problem, expectedMention: "unknownField", label: "AZ-796 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "unknownField", label: "AZ-796 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field rejected; errors map names the field");
|
||||
}
|
||||
@@ -344,7 +344,7 @@ public static class TileInventoryValidationTests
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown nested field");
|
||||
AssertErrorsContainsMention(problem, expectedMention: "foo", label: "AZ-796 unknown nested field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "foo", label: "AZ-796 unknown nested field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown nested field rejected; errors map names the field");
|
||||
}
|
||||
@@ -364,7 +364,7 @@ public static class TileInventoryValidationTests
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-794 legacy field");
|
||||
AssertErrorsContainsMention(problem, expectedMention: "tileZoom", label: "AZ-794 legacy field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "tileZoom", label: "AZ-794 legacy field");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy v1.x field names rejected with explicit error (no silent coercion)");
|
||||
}
|
||||
@@ -392,39 +392,4 @@ public static class TileInventoryValidationTests
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(InventoryPath, content);
|
||||
}
|
||||
|
||||
private static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label)
|
||||
{
|
||||
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new Exception($"{label}: expected 'errors' object in ProblemDetails body.");
|
||||
}
|
||||
|
||||
var found = false;
|
||||
foreach (var prop in errorsEl.EnumerateObject())
|
||||
{
|
||||
if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var msg in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name));
|
||||
throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public static class TileTests
|
||||
|
||||
Console.WriteLine($"Getting tile at coordinates ({latitude}, {longitude}) with zoom level {zoomLevel}");
|
||||
|
||||
var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ public static class TileTests
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Testing tile reuse (getting same tile again)...");
|
||||
|
||||
var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}");
|
||||
|
||||
if (!response2.IsSuccessStatusCode)
|
||||
{
|
||||
|
||||
@@ -511,9 +511,14 @@ public static class UavUploadTests
|
||||
private static (double Latitude, double Longitude) NextTestCoordinate()
|
||||
{
|
||||
// Spread test coordinates far enough apart to fall into distinct tile cells
|
||||
// so concurrent runs don't collide on the per-source unique index.
|
||||
// so concurrent runs don't collide on the per-source unique index. Wrap on
|
||||
// 40_000-cell axes so the result always stays strictly inside the
|
||||
// OSM-valid ranges enforced by UavTileMetadataValidator (AZ-810):
|
||||
// lat in [50.0, 70.0), lon in [10.0, 40.0).
|
||||
var n = Interlocked.Increment(ref _coordinateCounter);
|
||||
return (60.0 + n * 0.0005, 30.0 + n * 0.0005);
|
||||
var lat = 50.0 + ((uint)n % 40_000u) * 0.0005;
|
||||
var lon = 10.0 + ((uint)n % 60_000u) * 0.0005;
|
||||
return (lat, lon);
|
||||
}
|
||||
|
||||
private static async Task<int> CountUavRowsAsync(string connectionString, double latitude, double longitude)
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-810: end-to-end coverage for POST /api/satellite/upload strict metadata
|
||||
// validation. Each test exercises one of the 14 rules listed in the AZ-810
|
||||
// task spec and asserts the response conforms to the RFC 7807
|
||||
// ValidationProblemDetails contract in
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// The endpoint is multipart/form-data, so the validator wires in through the
|
||||
// custom `UavUploadValidationFilter` (NOT the generic `WithValidation<T>()`
|
||||
// filter that the JSON-body endpoints use). Three enforcement layers compose:
|
||||
// 1. UnmappedMemberHandling.Disallow + [JsonRequired] — the metadata JSON
|
||||
// is deserialized inside the filter via the strict global
|
||||
// `JsonSerializerOptions`; missing-required and unknown fields raise
|
||||
// JsonException which the filter surfaces under `errors["metadata"]`.
|
||||
// 2. UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator —
|
||||
// FluentValidation rules on the deserialized payload (item count, per-
|
||||
// item lat/lon/zoom/size/freshness). Errors are prefixed with
|
||||
// `metadata.` so paths look like `errors["metadata.items[0].latitude"]`.
|
||||
// 3. Cross-field envelope rule (items.Count == files.Count) — runs after
|
||||
// the per-payload validator; surfaces under `errors["metadata.items"]`
|
||||
// AND `errors["files"]`.
|
||||
//
|
||||
// AC-9 (no regression in existing UavUploadTests) is enforced by leaving the
|
||||
// pre-AZ-810 happy path here as a separate scenario and by exercising the
|
||||
// existing AZ-488 suite unchanged from Program.Main.
|
||||
public static class UavUploadValidationTests
|
||||
{
|
||||
private const string UploadPath = "/api/satellite/upload";
|
||||
private const string GpsPermission = "GPS";
|
||||
private const string PermissionsClaimType = "permissions";
|
||||
|
||||
public static async Task RunAll(string apiUrl, string secret)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/upload strict metadata validation (AZ-810)");
|
||||
|
||||
// AC-2: happy path unchanged (well-formed multipart envelope still 200).
|
||||
await HappyPath_Returns200(apiUrl, secret);
|
||||
|
||||
// Rule 2: metadata form field absent
|
||||
await MissingMetadataField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 3: metadata JSON malformed
|
||||
await MalformedMetadataJson_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 4: items missing (empty)
|
||||
await EmptyItems_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 5: items count > MaxBatchSize
|
||||
await ItemsOverCap_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 6: items.Count != files.Count
|
||||
await ItemsFilesMismatch_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 7: per-item lat out of range
|
||||
await ItemLatOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 8: per-item lon out of range
|
||||
await ItemLonOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 9: per-item tileZoom out of range
|
||||
await ItemTileZoomOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 10: per-item tileSizeMeters <= 0
|
||||
await ItemTileSizeMetersNonPositive_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 11a: capturedAt too far in the future
|
||||
await ItemCapturedAtFuture_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 11b: capturedAt older than MaxAgeDays
|
||||
await ItemCapturedAtTooOld_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 12: malformed flightId UUID (deserializer JsonException path)
|
||||
await ItemFlightIdMalformed_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 13: unknown field at the root of metadata
|
||||
await UnknownRootField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 13b: unknown field nested under items[i]
|
||||
await UnknownNestedField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 14: type mismatch (lat as string)
|
||||
await ItemLatTypeMismatch_Returns400(apiUrl, secret);
|
||||
|
||||
Console.WriteLine("✓ UAV upload metadata validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 AC-2: well-formed metadata + 1 valid file → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
latitude = coord.Latitude,
|
||||
longitude = coord.Longitude,
|
||||
tileZoom = 18,
|
||||
tileSizeMeters = 200.0,
|
||||
capturedAt = DateTime.UtcNow.ToString("o"),
|
||||
},
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.OK, "AZ-810 AC-2 happy path");
|
||||
Console.WriteLine(" ✓ Well-formed multipart envelope accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task MissingMetadataField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 2: missing `metadata` form field → HTTP 400");
|
||||
|
||||
// Arrange — multipart body with only the `files` part, no `metadata`.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 missing metadata");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 missing metadata");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 missing metadata");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `metadata` form field rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MalformedMetadataJson_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 3: malformed metadata JSON → HTTP 400");
|
||||
|
||||
// Arrange — unterminated JSON object.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
const string brokenJson = "{\"items\": [{ \"latitude\": 50.10, \"longitude\": 36.10";
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(brokenJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 malformed JSON");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 malformed JSON");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 malformed JSON");
|
||||
|
||||
Console.WriteLine(" ✓ Malformed metadata JSON rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task EmptyItems_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 4: empty `items` → HTTP 400");
|
||||
|
||||
// Arrange — well-formed JSON, but items: [] tripping FluentValidation.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
var metadata = new { items = Array.Empty<object>() };
|
||||
|
||||
// Act — no files either; the items rule fires before the count-mismatch rule.
|
||||
using var response = await PostBatch(client, metadata, Array.Empty<byte[]>());
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 empty items");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 empty items");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 empty items");
|
||||
|
||||
Console.WriteLine(" ✓ Empty items rejected with errors mention of `items`");
|
||||
}
|
||||
|
||||
private static async Task ItemsOverCap_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 5: items.Count > MaxBatchSize → HTTP 400 from validator");
|
||||
|
||||
// Arrange — 101 metadata entries + 101 tiny placeholders so this exercises
|
||||
// the AZ-810 validator path specifically (the count-mismatch rule does not
|
||||
// fire because items.Count == files.Count).
|
||||
const int oversize = 101;
|
||||
var baseCoord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = Enumerable.Range(0, oversize).Select(i => new
|
||||
{
|
||||
latitude = baseCoord.Latitude + i * 0.0001,
|
||||
longitude = baseCoord.Longitude,
|
||||
tileZoom = 18,
|
||||
tileSizeMeters = 200.0,
|
||||
capturedAt = DateTime.UtcNow.ToString("o"),
|
||||
}).ToArray(),
|
||||
};
|
||||
var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
|
||||
var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, files);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items over cap");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items over cap");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items over cap");
|
||||
|
||||
Console.WriteLine(" ✓ items.Count=101 (> 100 cap) rejected with errors mention of `items`");
|
||||
}
|
||||
|
||||
private static async Task ItemsFilesMismatch_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 6: items.Count != files.Count → HTTP 400");
|
||||
|
||||
// Arrange — 2 metadata items but only 1 file.
|
||||
var c1 = NextTestCoordinate();
|
||||
var c2 = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = c1.Latitude, longitude = c1.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
new { latitude = c2.Latitude, longitude = c2.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items/files mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items/files mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items/files mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "files", label: "AZ-810 items/files mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ items.Count=2 / files.Count=1 rejected with both `items` and `files` mentioned");
|
||||
}
|
||||
|
||||
private static async Task ItemLatOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 7: per-item latitude out of range → HTTP 400 (errors[metadata.items[i].latitude])");
|
||||
|
||||
// Arrange — second item has lat = 91.0 (above the +90 bound).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
new { latitude = 91.0, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg(), CreateValidJpeg(seed: 2) });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lat out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[1].latitude", label: "AZ-810 item lat out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[1].latitude=91.0 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemLonOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 8: per-item longitude out of range → HTTP 400 (errors[metadata.items[i].longitude])");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = 181.0, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lon out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].longitude", label: "AZ-810 item lon out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].longitude=181 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemTileZoomOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 9: per-item tileZoom out of range → HTTP 400");
|
||||
|
||||
// Arrange — zoom = 30 (above the 22 cap).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 30, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileZoom out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileZoom out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileZoom", label: "AZ-810 item tileZoom out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].tileZoom=30 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemTileSizeMetersNonPositive_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 10: per-item tileSizeMeters <= 0 → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 0.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileSizeMeters non-positive");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileSizeMeters non-positive");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileSizeMeters", label: "AZ-810 item tileSizeMeters non-positive");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].tileSizeMeters=0.0 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemCapturedAtFuture_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 11a: per-item capturedAt > now + CapturedAtFutureSkewSeconds → HTTP 400");
|
||||
|
||||
// Arrange — 1 hour in the future (default skew is 30s).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddHours(1).ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt future");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt future");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt future");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].capturedAt = now+1h rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemCapturedAtTooOld_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 11b: per-item capturedAt older than MaxAgeDays → HTTP 400");
|
||||
|
||||
// Arrange — 60 days old (default MaxAgeDays is 7).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddDays(-60).ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt too old");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt too old");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt too old");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 12: malformed flightId → HTTP 400 (JsonException at deserializer)");
|
||||
|
||||
// Arrange — flightId is a non-UUID string. System.Text.Json rejects this at
|
||||
// the deserializer; the filter catches the JsonException and surfaces it as
|
||||
// errors["metadata"].
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
|
||||
"flightId": "not-a-uuid"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 flightId malformed");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 flightId malformed");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 flightId malformed");
|
||||
|
||||
Console.WriteLine(" ✓ flightId=\"not-a-uuid\" rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task UnknownRootField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 13: unknown root field in metadata → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange — `debug` is not a member of UavTileBatchMetadataPayload.
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
|
||||
}
|
||||
],
|
||||
"debug": "fingerprint-probe"
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown root field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task UnknownNestedField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 13b: unknown nested field under items[i] → HTTP 400");
|
||||
|
||||
// Arrange — `altitude` is not a member of UavTileMetadata.
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
|
||||
"altitude": 500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown nested field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown nested field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown nested field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown nested field `altitude` rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task ItemLatTypeMismatch_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 14: nested type mismatch (`items[0].latitude` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": "fifty",
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 lat type mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 lat type mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].latitude=\"fifty\" rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static HttpClient CreateClientWithGpsToken(string apiUrl, string secret)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
|
||||
var token = JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, GpsPermission) });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)
|
||||
{
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(JsonSerializer.Serialize(metadata)), "metadata" },
|
||||
};
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
var item = new ByteArrayContent(files[i]);
|
||||
item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(item, "files", $"tile_{i}.jpg");
|
||||
}
|
||||
|
||||
return await client.PostAsync(UploadPath, content);
|
||||
}
|
||||
|
||||
private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label)
|
||||
{
|
||||
if (response.StatusCode != expected)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
var random = new Random(seed);
|
||||
image.ProcessPixelRows(accessor =>
|
||||
{
|
||||
for (var y = 0; y < accessor.Height; y++)
|
||||
{
|
||||
var row = accessor.GetRowSpan(y);
|
||||
for (var x = 0; x < row.Length; x++)
|
||||
{
|
||||
row[x] = new Rgba32(
|
||||
(byte)random.Next(256),
|
||||
(byte)random.Next(256),
|
||||
(byte)random.Next(256));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
image.Save(stream, new JpegEncoder { Quality = 95 });
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
// Use a southern-hemisphere range that does NOT overlap UavUploadTests'
|
||||
// northern range ([50,70) x [10,40)). Non-overlap (not counter offset) is
|
||||
// what guarantees the AZ-488 and AZ-810 suites don't collide on the
|
||||
// per-source UNIQUE index when both run against the same DB. Wrap on
|
||||
// 40_000-cell axes so the result always stays strictly inside the
|
||||
// OSM-valid ranges enforced by UavTileMetadataValidator:
|
||||
// lat in [-70.0, -50.0), lon in [-40.0, -10.0).
|
||||
private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);
|
||||
|
||||
private static (double Latitude, double Longitude) NextTestCoordinate()
|
||||
{
|
||||
var n = Interlocked.Increment(ref _coordinateCounter);
|
||||
var lat = -50.0 - ((uint)n % 40_000u) * 0.0005;
|
||||
var lon = -10.0 - ((uint)n % 60_000u) * 0.0005;
|
||||
return (lat, lon);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-809: unit tests for CreateRouteRequestValidator. Each RuleFor /
|
||||
// RuleForEach in the root validator has at least one passing case + one
|
||||
// failing case. Required-field detection lives at the deserializer layer
|
||||
// ([JsonRequired] + UnmappedMemberHandling.Disallow), covered separately
|
||||
// at the integration layer in CreateRouteValidationTests.
|
||||
public class CreateRouteRequestValidatorTests
|
||||
{
|
||||
private readonly CreateRouteRequestValidator _validator;
|
||||
|
||||
public CreateRouteRequestValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
_validator = new CreateRouteRequestValidator();
|
||||
}
|
||||
|
||||
private static CreateRouteRequest ValidRequest()
|
||||
{
|
||||
return new CreateRouteRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "derkachi-flight-1",
|
||||
Description = "AZ-777 Phase 2 seed route",
|
||||
RegionSizeMeters = 1000.0,
|
||||
ZoomLevel = 18,
|
||||
Points = new List<RoutePoint>
|
||||
{
|
||||
new() { Latitude = 50.10, Longitude = 36.10 },
|
||||
new() { Latitude = 50.11, Longitude = 36.11 },
|
||||
},
|
||||
RequestMaps = true,
|
||||
CreateTilesZip = false,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllValid_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest();
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_IdEmpty_FailsNotEmptyRule()
|
||||
{
|
||||
// Arrange — reproduces the 2026-05-22 probe finding (silent zero-Guid).
|
||||
var request = ValidRequest();
|
||||
request.Id = Guid.Empty;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("id")
|
||||
.WithErrorMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_NameMissing_FailsNotEmptyRule(string name)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest();
|
||||
request.Name = name;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameTooLong_FailsLengthRule()
|
||||
{
|
||||
// Arrange — name length 201 (cap is 200).
|
||||
var request = ValidRequest();
|
||||
request.Name = new string('a', 201);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DescriptionTooLong_FailsLengthRule()
|
||||
{
|
||||
// Arrange — description length 1001 (cap is 1000).
|
||||
var request = ValidRequest();
|
||||
request.Description = new string('d', 1001);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("description");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(99.999)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(10000.001)]
|
||||
[InlineData(100000.0)]
|
||||
public void Validate_RegionSizeMetersOutOfRange_FailsRangeRule(double size)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest();
|
||||
request.RegionSizeMeters = size;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("regionSizeMeters");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(23)]
|
||||
[InlineData(100)]
|
||||
public void Validate_ZoomLevelOutOfRange_FailsRangeRule(int zoom)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest();
|
||||
request.ZoomLevel = zoom;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("zoomLevel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PointsTooFew_FailsCountRule()
|
||||
{
|
||||
// Arrange — only 1 point; min is 2 (Flow F4 precondition).
|
||||
var request = ValidRequest();
|
||||
request.Points = new List<RoutePoint>
|
||||
{
|
||||
new() { Latitude = 50.10, Longitude = 36.10 },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("points");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PointsTooMany_FailsCountRule()
|
||||
{
|
||||
// Arrange — 501 points; max is 500.
|
||||
var request = ValidRequest();
|
||||
request.Points = Enumerable
|
||||
.Range(0, 501)
|
||||
.Select(_ => new RoutePoint { Latitude = 50.10, Longitude = 36.10 })
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("points");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PointLatOutOfRange_FailsChildRule()
|
||||
{
|
||||
// Arrange — second point's lat is out of range
|
||||
var request = ValidRequest();
|
||||
request.Points[1].Latitude = 91.0;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("points[1].lat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PointLonOutOfRange_FailsChildRule()
|
||||
{
|
||||
// Arrange — second point's lon is out of range
|
||||
var request = ValidRequest();
|
||||
request.Points[1].Longitude = 181.0;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("points[1].lon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GeofencePolygonNwSwapped_FailsChildInvariant()
|
||||
{
|
||||
// Arrange — NW.Lat <= SE.Lat (NW not north-of SE)
|
||||
var request = ValidRequest();
|
||||
request.Geofences = new Geofences
|
||||
{
|
||||
Polygons = new List<GeofencePolygon>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NorthWest = new GeoPoint(50.05, 36.05),
|
||||
SouthEast = new GeoPoint(50.05, 36.15),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert — the GeofencePolygonValidator child-validator's `.WithName("northWest")`
|
||||
// is prefixed with the RuleForEach path which we OverridePropertyName to
|
||||
// "geofences.polygons", producing the full wire path
|
||||
// `geofences.polygons[0].northWest`.
|
||||
result.ShouldHaveValidationErrorFor("geofences.polygons[0].northWest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_GeofencesPresentButEmpty_FailsNotEmptyRule()
|
||||
{
|
||||
// Arrange — geofences object exists, polygons list is empty
|
||||
var request = ValidRequest();
|
||||
request.Geofences = new Geofences { Polygons = new List<GeofencePolygon>() };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert — OverridePropertyName makes the empty-list rule fire at the
|
||||
// wire-format path `geofences.polygons` instead of the leaf-only `polygons`.
|
||||
result.ShouldHaveValidationErrorFor("geofences.polygons");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CreateTilesZipWithoutRequestMaps_FailsCrossFieldRule()
|
||||
{
|
||||
// Arrange — cannot zip what wasn't downloaded
|
||||
var request = ValidRequest();
|
||||
request.RequestMaps = false;
|
||||
request.CreateTilesZip = true;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("createTilesZip")
|
||||
.WithErrorMessage("`createTilesZip` requires `requestMaps` to be true (can't zip what wasn't downloaded).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CreateTilesZipWithRequestMaps_Passes()
|
||||
{
|
||||
// Arrange — both true is valid
|
||||
var request = ValidRequest();
|
||||
request.RequestMaps = true;
|
||||
request.CreateTilesZip = true;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("createTilesZip");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-809: unit tests for GeofencePolygonValidator. Covers (a) presence of
|
||||
// both corners, (b) range checks per corner, and (c) the cross-field
|
||||
// invariant `NW north-of SE` AND `NW west-of SE`.
|
||||
public class GeofencePolygonValidatorTests
|
||||
{
|
||||
private readonly GeofencePolygonValidator _validator;
|
||||
|
||||
public GeofencePolygonValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
_validator = new GeofencePolygonValidator();
|
||||
}
|
||||
|
||||
private static GeofencePolygon ValidPolygon() => new()
|
||||
{
|
||||
NorthWest = new GeoPoint(50.15, 36.05),
|
||||
SouthEast = new GeoPoint(50.05, 36.15),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllValid_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var polygon = ValidPolygon();
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NorthWestNull_FailsNotNullRule()
|
||||
{
|
||||
// Arrange
|
||||
var polygon = ValidPolygon();
|
||||
polygon.NorthWest = null;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("northWest")
|
||||
.WithErrorMessage("`northWest` corner is required.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SouthEastNull_FailsNotNullRule()
|
||||
{
|
||||
// Arrange
|
||||
var polygon = ValidPolygon();
|
||||
polygon.SouthEast = null;
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("southEast")
|
||||
.WithErrorMessage("`southEast` corner is required.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.001)]
|
||||
[InlineData(90.001)]
|
||||
public void Validate_NorthWestLatOutOfRange_FailsRangeRule(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var polygon = ValidPolygon();
|
||||
polygon.NorthWest = new GeoPoint(lat, 36.05);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("northWest.lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.001)]
|
||||
[InlineData(180.001)]
|
||||
public void Validate_SouthEastLonOutOfRange_FailsRangeRule(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var polygon = ValidPolygon();
|
||||
polygon.SouthEast = new GeoPoint(50.05, lon);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("southEast.lon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NorthWestLatNotGreaterThanSouthEast_FailsInvariant()
|
||||
{
|
||||
// Arrange — NW.Lat <= SE.Lat → invariant violation
|
||||
var polygon = ValidPolygon();
|
||||
polygon.NorthWest = new GeoPoint(50.05, 36.05);
|
||||
polygon.SouthEast = new GeoPoint(50.05, 36.15);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("northWest")
|
||||
.WithErrorMessage("`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NorthWestLonNotLessThanSouthEast_FailsInvariant()
|
||||
{
|
||||
// Arrange — NW.Lon >= SE.Lon → invariant violation
|
||||
var polygon = ValidPolygon();
|
||||
polygon.NorthWest = new GeoPoint(50.15, 36.15);
|
||||
polygon.SouthEast = new GeoPoint(50.05, 36.15);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(polygon);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("northWest")
|
||||
.WithErrorMessage("`northWest.lon` must be less than `southEast.lon` (NW is west-of SE).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SatelliteProvider.Api.DTOs;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-811: unit tests for GetTileByLatLonQueryValidator. One Theory per RuleFor
|
||||
// covering boundary + out-of-range. Unknown-query-param rejection is tested
|
||||
// at the integration layer (GetTileByLatLonValidationTests) — there's no
|
||||
// pure-unit equivalent because the filter runs against HttpContext.Request.Query.
|
||||
public class GetTileByLatLonQueryValidatorTests
|
||||
{
|
||||
private readonly GetTileByLatLonQueryValidator _validator;
|
||||
|
||||
public GetTileByLatLonQueryValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
_validator = new GetTileByLatLonQueryValidator();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.001)]
|
||||
[InlineData(90.001)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LatOutOfRange_FailsRangeRule(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(lat, 37.647063, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LatNull_FailsNotNullRule()
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(null, 37.647063, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert — CascadeMode.Stop ensures NotNull short-circuits the range
|
||||
// rule, so the caller sees only `"\`lat\` is required."` not also the
|
||||
// range error against a null sentinel.
|
||||
result.ShouldHaveValidationErrorFor("lat").WithErrorMessage("`lat` is required.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(47.461747)]
|
||||
[InlineData(90.0)]
|
||||
public void Validate_LatAtOrInsideBounds_Passes(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(lat, 37.647063, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.001)]
|
||||
[InlineData(180.001)]
|
||||
[InlineData(360.0)]
|
||||
public void Validate_LonOutOfRange_FailsRangeRule(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, lon, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LonNull_FailsNotNullRule()
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, null, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lon").WithErrorMessage("`lon` is required.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(37.647063)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LonAtOrInsideBounds_Passes(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, lon, 18);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lon");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(23)]
|
||||
[InlineData(100)]
|
||||
public void Validate_ZoomOutOfRange_FailsRangeRule(int zoom)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, 37.647063, zoom);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("zoom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZoomNull_FailsNotNullRule()
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, 37.647063, null);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("zoom").WithErrorMessage("`zoom` is required.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(18)]
|
||||
[InlineData(22)]
|
||||
public void Validate_ZoomAtOrInsideBounds_Passes(int zoom)
|
||||
{
|
||||
// Arrange
|
||||
var query = new GetTileByLatLonQuery(47.461747, 37.647063, zoom);
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(query);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("zoom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-808: unit tests for RegionRequestValidator. Each RuleFor in the validator
|
||||
// has at least one passing case + one failing case. Required-field detection
|
||||
// (id / lat / lon / sizeMeters / zoomLevel / stitchTiles) is not unit-tested
|
||||
// here because it lives at the deserializer layer (JsonRequired), not the
|
||||
// validator — covered by the integration tests (RegionRequestValidationTests).
|
||||
public class RegionRequestValidatorTests
|
||||
{
|
||||
private readonly RegionRequestValidator _validator;
|
||||
|
||||
public RegionRequestValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
_validator = new RegionRequestValidator();
|
||||
}
|
||||
|
||||
private static RequestRegionRequest ValidRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Lat = 47.461747,
|
||||
Lon = 37.647063,
|
||||
SizeMeters = 200.0,
|
||||
ZoomLevel = 18,
|
||||
StitchTiles = false,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllValid_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest();
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_IdEmpty_FailsNotEmptyRule()
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { Id = Guid.Empty };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("id")
|
||||
.WithErrorMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.001)]
|
||||
[InlineData(90.001)]
|
||||
[InlineData(180.0)]
|
||||
[InlineData(-181.0)]
|
||||
public void Validate_LatOutOfRange_FailsRangeRule(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { Lat = lat };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(47.461747)]
|
||||
[InlineData(90.0)]
|
||||
public void Validate_LatAtOrInsideBounds_Passes(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { Lat = lat };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.001)]
|
||||
[InlineData(180.001)]
|
||||
[InlineData(360.0)]
|
||||
public void Validate_LonOutOfRange_FailsRangeRule(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { Lon = lon };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lon");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(37.647063)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LonAtOrInsideBounds_Passes(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { Lon = lon };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lon");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(99.999)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(10000.001)]
|
||||
[InlineData(100000.0)]
|
||||
[InlineData(-1.0)]
|
||||
public void Validate_SizeMetersOutOfRange_FailsRangeRule(double sizeMeters)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { SizeMeters = sizeMeters };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("sizeMeters");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100.0)]
|
||||
[InlineData(200.0)]
|
||||
[InlineData(5000.0)]
|
||||
[InlineData(10000.0)]
|
||||
public void Validate_SizeMetersAtOrInsideBounds_Passes(double sizeMeters)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { SizeMeters = sizeMeters };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("sizeMeters");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(23)]
|
||||
[InlineData(100)]
|
||||
public void Validate_ZoomLevelOutOfRange_FailsRangeRule(int zoomLevel)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { ZoomLevel = zoomLevel };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("zoomLevel");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(18)]
|
||||
[InlineData(22)]
|
||||
public void Validate_ZoomLevelAtOrInsideBounds_Passes(int zoomLevel)
|
||||
{
|
||||
// Arrange
|
||||
var request = ValidRequest() with { ZoomLevel = zoomLevel };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("zoomLevel");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-811: unit coverage for the envelope filter that runs ahead of the
|
||||
// FluentValidation layer on query-string endpoints. Spec section 5 calls for
|
||||
// ≥ 1 unit test on this filter; integration coverage is in
|
||||
// SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs.
|
||||
public class RejectUnknownQueryParamsEndpointFilterTests
|
||||
{
|
||||
private static readonly string[] AllowedKeys = ["lat", "lon", "zoom"];
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AllKeysAllowed_DelegatesToNext()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys);
|
||||
var ctx = BuildContext(new Dictionary<string, StringValues>
|
||||
{
|
||||
["lat"] = "47.461747",
|
||||
["lon"] = "37.647063",
|
||||
["zoom"] = "18"
|
||||
});
|
||||
var sentinel = new object();
|
||||
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(sentinel);
|
||||
|
||||
// Act
|
||||
var result = await filter.InvokeAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(sentinel, "the filter must pass through when all query keys are in the allowed set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_UnknownKey_ReturnsValidationProblemAndDoesNotDelegate()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys);
|
||||
var ctx = BuildContext(new Dictionary<string, StringValues>
|
||||
{
|
||||
["lat"] = "47.461747",
|
||||
["lon"] = "37.647063",
|
||||
["zoom"] = "18",
|
||||
["debug"] = "1"
|
||||
});
|
||||
var nextCalled = false;
|
||||
EndpointFilterDelegate next = _ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return ValueTask.FromResult<object?>(new object());
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await filter.InvokeAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeFalse("an unknown key must short-circuit the pipeline before the handler runs");
|
||||
var problem = result.Should().BeOfType<ProblemHttpResult>().Subject;
|
||||
problem.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
problem.ProblemDetails.Should().BeOfType<HttpValidationProblemDetails>();
|
||||
var validation = (HttpValidationProblemDetails)problem.ProblemDetails;
|
||||
validation.Errors.Should().ContainKey("debug");
|
||||
validation.Errors["debug"][0].Should().Contain("Unknown query parameter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_LegacyPascalCaseKeys_ReturnsErrorsPerKey()
|
||||
{
|
||||
// Arrange — AZ-811 envelope must catch the exact pre-rename wire format
|
||||
// (`Latitude/Longitude/ZoomLevel`) because case-insensitive lookup against
|
||||
// the allowed set still treats those keys as distinct from `lat/lon/zoom`.
|
||||
var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys);
|
||||
var ctx = BuildContext(new Dictionary<string, StringValues>
|
||||
{
|
||||
["Latitude"] = "47.461747",
|
||||
["Longitude"] = "37.647063",
|
||||
["ZoomLevel"] = "18"
|
||||
});
|
||||
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(new object());
|
||||
|
||||
// Act
|
||||
var result = await filter.InvokeAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
var problem = result.Should().BeOfType<ProblemHttpResult>().Subject;
|
||||
var validation = (HttpValidationProblemDetails)problem.ProblemDetails;
|
||||
validation.Errors.Should().ContainKey("Latitude");
|
||||
validation.Errors.Should().ContainKey("Longitude");
|
||||
validation.Errors.Should().ContainKey("ZoomLevel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_KeysAreCaseInsensitiveAgainstAllowedSet()
|
||||
{
|
||||
// Arrange — `Lat` (capital L) is the SAME allowed key as `lat`
|
||||
// (`StringComparer.OrdinalIgnoreCase`). It must pass through.
|
||||
var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys);
|
||||
var ctx = BuildContext(new Dictionary<string, StringValues>
|
||||
{
|
||||
["Lat"] = "47.461747",
|
||||
["lon"] = "37.647063",
|
||||
["ZOOM"] = "18"
|
||||
});
|
||||
var sentinel = new object();
|
||||
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(sentinel);
|
||||
|
||||
// Act
|
||||
var result = await filter.InvokeAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(sentinel);
|
||||
}
|
||||
|
||||
private static EndpointFilterInvocationContext BuildContext(IDictionary<string, StringValues> queryParams)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Query = new QueryCollection(queryParams.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
return new DefaultEndpointFilterInvocationContext(httpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-809: unit tests for RoutePointValidator. Lat/lon range checks live on
|
||||
// `RoutePoint.Latitude` / `RoutePoint.Longitude` (C# names); the validator's
|
||||
// OverridePropertyName aligns FluentValidation error keys with the wire
|
||||
// format (`lat` / `lon`) so callers see what they posted.
|
||||
public class RoutePointValidatorTests
|
||||
{
|
||||
private readonly RoutePointValidator _validator;
|
||||
|
||||
public RoutePointValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
_validator = new RoutePointValidator();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.001)]
|
||||
[InlineData(90.001)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LatOutOfRange_FailsRangeRule(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var point = new RoutePoint { Latitude = lat, Longitude = 37.647063 };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(point);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(47.461747)]
|
||||
[InlineData(90.0)]
|
||||
public void Validate_LatAtOrInsideBounds_Passes(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var point = new RoutePoint { Latitude = lat, Longitude = 37.647063 };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(point);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lat");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.001)]
|
||||
[InlineData(180.001)]
|
||||
[InlineData(360.0)]
|
||||
public void Validate_LonOutOfRange_FailsRangeRule(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var point = new RoutePoint { Latitude = 47.461747, Longitude = lon };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(point);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("lon");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-180.0)]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(37.647063)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LonAtOrInsideBounds_Passes(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var point = new RoutePoint { Latitude = 47.461747, Longitude = lon };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(point);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("lon");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-810: root metadata-envelope validator tests. Covers `items` non-null +
|
||||
// non-empty + cap rules. The per-item rules are covered by UavTileMetadataValidatorTests.
|
||||
public class UavTileBatchMetadataPayloadValidatorTests
|
||||
{
|
||||
private readonly UavTileBatchMetadataPayloadValidator _validator;
|
||||
private readonly DateTime _now;
|
||||
|
||||
public UavTileBatchMetadataPayloadValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
var config = Options.Create(new UavQualityConfig
|
||||
{
|
||||
MaxBatchSize = 100,
|
||||
MaxAgeDays = 7,
|
||||
CapturedAtFutureSkewSeconds = 30,
|
||||
});
|
||||
_now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
_validator = new UavTileBatchMetadataPayloadValidator(config, new FixedTimeProvider(_now));
|
||||
}
|
||||
|
||||
private UavTileMetadata ValidItem() => new()
|
||||
{
|
||||
Latitude = 50.10,
|
||||
Longitude = 36.10,
|
||||
TileZoom = 18,
|
||||
TileSizeMeters = 200.0,
|
||||
CapturedAt = _now.AddMinutes(-5),
|
||||
FlightId = null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_OneValidItem_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new UavTileBatchMetadataPayload { Items = new() { ValidItem() } };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(payload);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ItemsEmpty_FailsNotEmptyRule()
|
||||
{
|
||||
// Arrange
|
||||
var payload = new UavTileBatchMetadataPayload { Items = new() };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(payload);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("items")
|
||||
.WithErrorMessage("`items` must contain at least one entry.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ItemsTooMany_FailsCountRule()
|
||||
{
|
||||
// Arrange — 101 items (cap = 100)
|
||||
var items = Enumerable.Range(0, 101).Select(_ => ValidItem()).ToList();
|
||||
var payload = new UavTileBatchMetadataPayload { Items = items };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(payload);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("items")
|
||||
.WithErrorMessage("`items` must contain at most 100 entries.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerItemFailure_PropagatesWithIndexedPath()
|
||||
{
|
||||
// Arrange — first item valid, second item lat out-of-range
|
||||
var payload = new UavTileBatchMetadataPayload
|
||||
{
|
||||
Items = new() { ValidItem(), ValidItem() with { Latitude = 91.0 } },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(payload);
|
||||
|
||||
// Assert — error key follows the wire format produced by RuleForEach.
|
||||
result.ShouldHaveValidationErrorFor("items[1].latitude");
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTime _utcNow;
|
||||
public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow;
|
||||
public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Tests.Validators;
|
||||
|
||||
// AZ-810: per-item metadata validator tests. Each RuleFor in
|
||||
// UavTileMetadataValidator gets at least one passing + one failing case.
|
||||
// Required-field detection lives at the deserializer layer ([JsonRequired]
|
||||
// on UavTileMetadata) and is exercised at the integration layer.
|
||||
public class UavTileMetadataValidatorTests
|
||||
{
|
||||
private readonly UavTileMetadataValidator _validator;
|
||||
private readonly DateTime _now;
|
||||
|
||||
public UavTileMetadataValidatorTests()
|
||||
{
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
var config = Options.Create(new UavQualityConfig
|
||||
{
|
||||
MaxAgeDays = 7,
|
||||
CapturedAtFutureSkewSeconds = 30,
|
||||
});
|
||||
_now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
_validator = new UavTileMetadataValidator(config, new FixedTimeProvider(_now));
|
||||
}
|
||||
|
||||
// Mirrors the existing pattern in UavTileUploadHandlerTests / UavTileQualityGateTests
|
||||
// (those tests inline the same shape). Kept private here for SRP; if a third
|
||||
// consumer appears, promote to SatelliteProvider.TestSupport.
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTime _utcNow;
|
||||
public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow;
|
||||
public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static UavTileMetadata ValidMetadata(DateTime capturedAt) => new()
|
||||
{
|
||||
Latitude = 50.10,
|
||||
Longitude = 36.10,
|
||||
TileZoom = 18,
|
||||
TileSizeMeters = 200.0,
|
||||
CapturedAt = capturedAt,
|
||||
FlightId = null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllValid_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now.AddMinutes(-5));
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-91.0)]
|
||||
[InlineData(90.001)]
|
||||
[InlineData(180.0)]
|
||||
public void Validate_LatOutOfRange_FailsRangeRule(double lat)
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now) with { Latitude = lat };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("latitude");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-181.0)]
|
||||
[InlineData(180.001)]
|
||||
[InlineData(360.0)]
|
||||
public void Validate_LonOutOfRange_FailsRangeRule(double lon)
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now) with { Longitude = lon };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("longitude");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(23)]
|
||||
[InlineData(100)]
|
||||
public void Validate_TileZoomOutOfRange_FailsRangeRule(int zoom)
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now) with { TileZoom = zoom };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("tileZoom");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(-1.0)]
|
||||
public void Validate_TileSizeMetersNonPositive_FailsGreaterThanRule(double size)
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now) with { TileSizeMeters = size };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("tileSizeMeters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CapturedAtFuture_FailsFreshnessRule()
|
||||
{
|
||||
// Arrange — 60s in the future (skew limit is 30s).
|
||||
var metadata = ValidMetadata(_now.AddSeconds(60));
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("capturedAt")
|
||||
.WithErrorMessage("`capturedAt` must be within 30s of the current time (no future-dated tiles).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CapturedAtNearFutureWithinSkew_Passes()
|
||||
{
|
||||
// Arrange — 10s in the future (within the 30s skew window).
|
||||
var metadata = ValidMetadata(_now.AddSeconds(10));
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("capturedAt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CapturedAtTooOld_FailsFreshnessRule()
|
||||
{
|
||||
// Arrange — 8 days ago (cap is 7 days).
|
||||
var metadata = ValidMetadata(_now.AddDays(-8));
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveValidationErrorFor("capturedAt")
|
||||
.WithErrorMessage("`capturedAt` must be within the last 7 days.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FlightIdNull_Passes()
|
||||
{
|
||||
// Arrange — AZ-503 anonymous-flight semantics: null FlightId is valid.
|
||||
var metadata = ValidMetadata(_now) with { FlightId = null };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("flightId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FlightIdSet_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ValidMetadata(_now) with { FlightId = Guid.NewGuid() };
|
||||
|
||||
// Act
|
||||
var result = _validator.TestValidate(metadata);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotHaveValidationErrorFor("flightId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# Contract: region-request
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RegionProcessing (`SatelliteProvider.Services.RegionProcessing`)
|
||||
**Producer task**: AZ-808 — `_docs/02_tasks/done/AZ-808_region_endpoint_validation.md` (validator + this contract); AZ-812 — `_docs/02_tasks/done/AZ-812_region_field_rename_to_osm.md` (OSM-convention wire-format `lat`/`lon`)
|
||||
**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (seeds Derkachi reference tile catalog via this endpoint)
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the HTTP contract for `POST /api/satellite/request` — the region-onboarding endpoint that enqueues a square region of tiles for asynchronous backfill from Google Maps. Callers submit a `(lat, lon, sizeMeters, zoomLevel)` envelope identified by a client-provided `id` (idempotency key); the API responds immediately with the queued region's status. Actual tile downloads run in the background via `RegionProcessingService` (`system-flows.md` Flow F2). Callers poll `GET /api/satellite/region/{id}` until `status == completed`.
|
||||
|
||||
This is the v1.0.0 of the contract — published alongside AZ-808's validator landing. There is no prior contract document. AZ-812 had already renamed the wire-format fields `Latitude/Longitude` → `lat/lon` (OSM convention) earlier in cycle 8; this contract publishes the post-rename shape directly with no transitional period.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/satellite/request
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401.
|
||||
|
||||
## Shape
|
||||
|
||||
### Request body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"lat": 47.461747,
|
||||
"lon": 37.647063,
|
||||
"sizeMeters": 200,
|
||||
"zoomLevel": 18,
|
||||
"stitchTiles": false
|
||||
}
|
||||
```
|
||||
|
||||
Per-field constraints:
|
||||
|
||||
| Field | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `id` | UUID | yes | Client-provided idempotency key. POSTing the same `id` twice returns the existing region (idempotent per AZ-362). | Non-zero GUID. `00000000-...` → HTTP 400. |
|
||||
| `lat` | number | yes | Region centre latitude (WGS84, decimal degrees). | `[-90.0, 90.0]`. |
|
||||
| `lon` | number | yes | Region centre longitude (WGS84, decimal degrees). | `[-180.0, 180.0]`. |
|
||||
| `sizeMeters` | number | yes | Square region side length. | `[100.0, 10000.0]`. Anything larger → HTTP 400. |
|
||||
| `zoomLevel` | integer | yes | Slippy-map zoom level for the resulting tiles. | `[0, 22]`. |
|
||||
| `stitchTiles` | bool | yes | If true, a stitched composite image is produced once all tiles are present. No default — caller MUST declare intent. | true / false. |
|
||||
|
||||
Strict parsing: unknown fields at root are rejected with HTTP 400 by `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). Missing required fields are caught by `[JsonRequired]` on the DTO and surface as HTTP 400 with the field name in `errors`.
|
||||
|
||||
### Response body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"status": "queued",
|
||||
"csvFilePath": null,
|
||||
"summaryFilePath": null,
|
||||
"tilesDownloaded": 0,
|
||||
"tilesReused": 0,
|
||||
"createdAt": "2026-05-22T12:34:56.789Z",
|
||||
"updatedAt": "2026-05-22T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-field semantics:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Echo of the request `id`. |
|
||||
| `status` | string enum | `"queued"` immediately after enqueue; transitions through `"processing"` → `"completed"` (or `"failed"`) on the background worker. |
|
||||
| `csvFilePath` | string \| null | Path to the per-region tile-manifest CSV. Null until processing produces it. |
|
||||
| `summaryFilePath` | string \| null | Path to the human-readable summary. Null until processing produces it. |
|
||||
| `tilesDownloaded` | integer | Count of tiles fetched fresh from Google Maps. Updated as processing progresses. |
|
||||
| `tilesReused` | integer | Count of tiles served from existing cache. Updated as processing progresses. |
|
||||
| `createdAt` | ISO-8601 UTC | Initial enqueue timestamp. Stable across retries (per AZ-362 idempotency). |
|
||||
| `updatedAt` | ISO-8601 UTC | Last status-row write. Bumps as the background worker progresses. |
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Method | Path | Request body | Response | Status codes |
|
||||
|--------|------|--------------|----------|--------------|
|
||||
| `POST` | `/api/satellite/request` | `RequestRegionRequest` | `RegionStatusResponse` | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies:
|
||||
|
||||
1. **JSON deserializer rules** — wire-format failures: unknown fields (`UnmappedMemberHandling.Disallow`), missing `[JsonRequired]` properties, type mismatches. Surface via `BadHttpRequestException(JsonException)` → `GlobalExceptionHandler`.
|
||||
2. **`RegionRequestValidator`** (FluentValidation, AZ-808) — business rules: non-zero `id`, range checks for `lat` / `lon` / `sizeMeters` / `zoomLevel`. Surface via `ValidationEndpointFilter<RequestRegionRequest>`.
|
||||
|
||||
Example body for a missing-id failure (the pre-AZ-808 silent-coercion gap surfaced by the 2026-05-22 black-box probe):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"id": ["The id field is required."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for a zero-Guid id (validator-level rejection):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"id": ["`id` must be a non-zero GUID (the caller's idempotency key)."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: `id` MUST be a non-zero GUID. Pre-AZ-808, omitting `id` silently coerced to `Guid.Empty` and queued a region under the zero key; AZ-808 fails this with HTTP 400 and `errors["id"]`.
|
||||
- **Inv-2**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`.
|
||||
- **Inv-3**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`.
|
||||
- **Inv-4**: `sizeMeters ∈ [100.0, 10000.0]`. Out-of-range → 400 with `errors["sizeMeters"]`. Pre-AZ-808 this rule lived as an inline `if` in the handler; AZ-808 moves it into the validator.
|
||||
- **Inv-5**: `zoomLevel ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8).
|
||||
- **Inv-6**: `stitchTiles` MUST be explicitly provided. No defaulting to `false` — callers declare intent.
|
||||
- **Inv-7**: Unknown fields at root are rejected with HTTP 400 + the field name in `errors`.
|
||||
- **Inv-8** (idempotency, AZ-362): Two POSTs with the same `id` return the existing region resource with HTTP 200 and do NOT enqueue duplicate background processing. The post-rename `lat`/`lon` wire format does not affect this invariant.
|
||||
- **Inv-9** (async semantics): The endpoint returns immediately after enqueuing. Status transitions to `completed`/`failed` happen on the background `RegionProcessingService`. Callers MUST poll `GET /api/satellite/region/{id}` to observe completion.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: tile body fetch. The background worker writes tiles into the `tiles` table; callers fetch bodies via `GET /tiles/{z}/{x}/{y}` after polling shows `status == completed`.
|
||||
- **Not covered**: backward-compatibility shim for `Latitude/Longitude` wire field names. AZ-812 ships v1.0.0 of this contract directly with the post-rename names; pre-rename callers receive HTTP 400 with `errors["latitude"]: ["could not be mapped"]`. There is no transitional accept-both period.
|
||||
- **Not covered**: geofencing semantics. Geofences are a Route concern, not a Region concern; documented in `route-create.md` (forthcoming, AZ-809).
|
||||
- **Not covered**: cancellation of a queued region. The current API has no DELETE / cancel verb. Tracked separately if needed.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour.
|
||||
- **Minor (1.x.0)**: Adding an optional response field consumers may safely ignore (e.g. ETA estimate); relaxing a range constraint within `[-90,90]` / `[-180,180]` envelope (e.g. accepting decimal degrees with extra precision).
|
||||
- **Major (2.0.0)**: Changing a field name; tightening a range constraint (breaks today's valid callers); making `stitchTiles` optional with a default again; removing idempotency.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| happy-path | `{id:<guid>, lat:47.46, lon:37.64, sizeMeters:200, zoomLevel:18, stitchTiles:false}` | HTTP 200 + RegionStatusResponse(status="queued") | AC-2 |
|
||||
| missing-id | body without `id` field | HTTP 400 + `errors["id"]` | Inv-1 (probe gap) |
|
||||
| zero-guid-id | `id: "00000000-..."` | HTTP 400 + `errors["id"]` | Inv-1 |
|
||||
| missing-lat | body without `lat` | HTTP 400 + `errors["lat"]` | JsonRequired |
|
||||
| lat-out-of-range | `lat: 91` | HTTP 400 + `errors["lat"]` | Inv-2 |
|
||||
| missing-lon | body without `lon` | HTTP 400 + `errors["lon"]` | JsonRequired |
|
||||
| lon-out-of-range | `lon: 181` | HTTP 400 + `errors["lon"]` | Inv-3 |
|
||||
| missing-sizeMeters | body without `sizeMeters` | HTTP 400 + `errors["sizeMeters"]` | JsonRequired |
|
||||
| sizeMeters-out-of-range | `sizeMeters: 1000000` | HTTP 400 + `errors["sizeMeters"]` | Inv-4 |
|
||||
| missing-zoomLevel | body without `zoomLevel` | HTTP 400 + `errors["zoomLevel"]` | JsonRequired |
|
||||
| zoomLevel-out-of-range | `zoomLevel: 30` | HTTP 400 + `errors["zoomLevel"]` | Inv-5 |
|
||||
| missing-stitchTiles | body without `stitchTiles` | HTTP 400 + `errors["stitchTiles"]` | Inv-6 |
|
||||
| lat-type-mismatch | `lat: "fifty"` | HTTP 400 (deserializer JsonException) | wire-format failure |
|
||||
| unknown-root-field | body with `unknownField: 1` | HTTP 400 + `errors["unknownField"]` | Inv-7 |
|
||||
| legacy-latitude-name | body with `latitude:` instead of `lat:` | HTTP 400 + `errors["latitude"]` | AZ-812 hard switch |
|
||||
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
|
||||
| idempotent-double-post | same body POSTed twice | both HTTP 200; same `createdAt`; no duplicate background work | AC-2 + AZ-362 |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/request`. Publishes the post-AZ-812 OSM-convention wire format (`lat`/`lon`) and the AZ-808 strict-validation rules (non-zero `id`, range-checked `lat`/`lon`/`sizeMeters`/`zoomLevel`, explicit `stitchTiles`, unknown-field rejection). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the downstream read path (callers seed via region, then read via inventory). | autodev (Step 10, cycle 8) |
|
||||
@@ -0,0 +1,213 @@
|
||||
# Contract: route-creation
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RouteManagement (`SatelliteProvider.Services.RouteManagement`) and feeding the background Route Map Processing flow (Flow F5)
|
||||
**Producer task**: AZ-809 — `_docs/02_tasks/done/AZ-809_route_endpoint_validation.md` (validator + this contract)
|
||||
**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (preferred imagery-seeding path — route-based rather than bbox-based)
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the HTTP contract for `POST /api/satellite/route` — the route-onboarding endpoint that stores an ordered set of waypoints, interpolates intermediate points every ~200 m, and (optionally, when `requestMaps=true`) enqueues a region request per route point so background processing pre-fetches map tiles for the entire route corridor. Geofence polygons (optional) restrict which intermediate points get region-requests. Callers poll `GET /api/satellite/route/{id}` until `mapsReady=true` (when `requestMaps=true`) or read the response directly (when `requestMaps=false`).
|
||||
|
||||
This is v1.0.0 — published alongside AZ-809's validator landing. There is no prior contract document; the producer-doc surface before AZ-809 was `modules/api_program.md::CreateRoute Handler` + Flow F4 + Flow F5 only.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/satellite/route
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401.
|
||||
|
||||
## Shape
|
||||
|
||||
### Request body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"name": "derkachi-flight-1",
|
||||
"description": "AZ-777 Phase 2 seed route",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{ "northWest": { "lat": 50.15, "lon": 36.05 },
|
||||
"southEast": { "lat": 50.05, "lon": 36.15 } }
|
||||
]
|
||||
},
|
||||
"requestMaps": true,
|
||||
"createTilesZip": false
|
||||
}
|
||||
```
|
||||
|
||||
Per-field constraints:
|
||||
|
||||
| Field | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `id` | UUID | yes (`[JsonRequired]`) | Caller-supplied idempotency key. POSTing twice with the same `id` returns the existing route resource. | Non-zero GUID (validator rejects `00000000-...`). |
|
||||
| `name` | string | yes (`[JsonRequired]`) | Human-readable route name (used in produced filenames). | Length `[1, 200]`. Empty/whitespace rejected. |
|
||||
| `description` | string | no | Free-text description. | Length `[0, 1000]` when present. |
|
||||
| `regionSizeMeters` | number | yes (`[JsonRequired]`) | Side length of the square region requested per route point. | `[100.0, 10000.0]` (aligned with `region-request.md::sizeMeters`). |
|
||||
| `zoomLevel` | integer | yes (`[JsonRequired]`) | Slippy-map zoom level for region tiles. | `[0, 22]`. |
|
||||
| `points` | array | yes (`[JsonRequired]`) | Ordered waypoints. Server interpolates additional intermediate points every ~200 m between consecutive originals. | Count `[2, 500]`. |
|
||||
| `points[i].lat` | number | yes (`[JsonRequired]`) | WGS84 latitude. | `[-90.0, 90.0]`. |
|
||||
| `points[i].lon` | number | yes (`[JsonRequired]`) | WGS84 longitude. | `[-180.0, 180.0]`. |
|
||||
| `geofences` | object | no | When present, intermediate points outside ALL polygons get filtered before region enqueue. | See nested shape below. |
|
||||
| `geofences.polygons` | array | yes (`[JsonRequired]` when `geofences` present) | One or more bbox polygons (NW corner + SE corner). | Non-empty when `geofences` present. |
|
||||
| `geofences.polygons[i].northWest` | object | yes (`[JsonRequired]`) | Polygon's northwest corner. | See `GeoPoint` shape. |
|
||||
| `geofences.polygons[i].southEast` | object | yes (`[JsonRequired]`) | Polygon's southeast corner. | See `GeoPoint` shape. |
|
||||
| `requestMaps` | bool | yes (`[JsonRequired]`) | When `true`, enqueue background region-requests for every route point inside the geofences (or all points if no geofences). | No default — caller must declare intent. |
|
||||
| `createTilesZip` | bool | yes (`[JsonRequired]`) | When `true`, AFTER all region tiles are ready, package them into a ZIP at `tilesZipPath`. Requires `requestMaps=true` (can't zip what wasn't downloaded). | No default. Cross-field invariant with `requestMaps`. |
|
||||
|
||||
`GeoPoint` shape (used by `northWest` / `southEast`):
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| `lat` | number | yes (`[JsonRequired]`) | `[-90.0, 90.0]`. |
|
||||
| `lon` | number | yes (`[JsonRequired]`) | `[-180.0, 180.0]`. |
|
||||
|
||||
Polygon corner cross-field invariant (`GeofencePolygonValidator`):
|
||||
- `northWest.lat > southEast.lat` (NW is genuinely north-of SE).
|
||||
- `northWest.lon < southEast.lon` (NW is genuinely west-of SE).
|
||||
|
||||
### Response body (post-AC-2 unchanged from pre-AZ-809)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
|
||||
"name": "derkachi-flight-1",
|
||||
"description": "AZ-777 Phase 2 seed route",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"totalDistanceMeters": 132.4,
|
||||
"totalPoints": 3,
|
||||
"points": [
|
||||
{ "latitude": 50.10, "longitude": 36.10, "pointType": "original", "sequenceNumber": 0, "segmentIndex": 0, "distanceFromPrevious": null },
|
||||
{ "latitude": 50.105, "longitude": 36.105, "pointType": "intermediate", "sequenceNumber": 1, "segmentIndex": 0, "distanceFromPrevious": 66.2 },
|
||||
{ "latitude": 50.11, "longitude": 36.11, "pointType": "original", "sequenceNumber": 2, "segmentIndex": 0, "distanceFromPrevious": 66.2 }
|
||||
],
|
||||
"requestMaps": true,
|
||||
"mapsReady": false,
|
||||
"csvFilePath": null,
|
||||
"summaryFilePath": null,
|
||||
"stitchedImagePath": null,
|
||||
"tilesZipPath": null,
|
||||
"createdAt": "2026-05-22T14:00:00Z",
|
||||
"updatedAt": "2026-05-22T14:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Advisory AC-10**: The response echoes points as `{"latitude":..,"longitude":..}` (legacy long form) but the request accepts `{"lat":..,"lon":..}` (OSM short form). This input/output asymmetry on the same `RoutePoint` round-trip is documented and intentional for v1.0.0 — fixing it would be a major contract break. A follow-up task can harmonize the response side.
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Method | Path | Request | Response | Status codes |
|
||||
|--------|------|---------|----------|--------------|
|
||||
| `POST` | `/api/satellite/route` | `CreateRouteRequest` body | `RouteResponse` (route resource snapshot) | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Three sources produce identically-shaped `ValidationProblemDetails` bodies:
|
||||
|
||||
1. **Deserializer envelope** (`UnmappedMemberHandling.Disallow` + `[JsonRequired]`) — rejects missing-required fields and unknown root/nested keys with `errors[<path>]` produced via `GlobalExceptionHandler`'s `JsonException` path.
|
||||
2. **`CreateRouteRequestValidator`** — rejects non-zero-Id, name/description length, range checks on size / zoom / points-count, and the cross-field `createTilesZip ⇒ requestMaps` rule.
|
||||
3. **`RoutePointValidator` + `GeofencePolygonValidator`** — invoked via `RuleForEach` / `SetValidator`; rejects per-point lat/lon out-of-range, per-polygon corner out-of-range, and the NW-north-of-SE / NW-west-of-SE invariants.
|
||||
|
||||
Example body for a missing-id failure (probe-confirmed pre-AZ-809 silent zero-Guid coercion):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"id": ["The JSON property 'id' is required, but a value was not supplied."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for a nested per-point failure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"errors": {
|
||||
"points[1].lat": ["`lat` must be between -90 and 90."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for a polygon corner invariant failure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"errors": {
|
||||
"geofences.polygons[0].northWest": ["`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE)."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: `id` is a non-zero GUID, supplied by the caller. Re-POST with the same id returns the existing route (idempotent contract per `IdempotentPostTests`).
|
||||
- **Inv-2**: `points` has at least 2 entries (Flow F4 precondition) and at most 500 entries (cap to prevent runaway region-enqueue).
|
||||
- **Inv-3**: Every `points[i].lat ∈ [-90, 90]` and `points[i].lon ∈ [-180, 180]`.
|
||||
- **Inv-4**: `regionSizeMeters ∈ [100, 10000]` (aligned with `region-request.md::sizeMeters`).
|
||||
- **Inv-5**: `zoomLevel ∈ [0, 22]` (slippy-map range, aligned with `region-request.md` Inv-5 and `tile-inventory.md` Inv-8).
|
||||
- **Inv-6** (cross-field): `createTilesZip=true ⇒ requestMaps=true` (can't zip what wasn't downloaded).
|
||||
- **Inv-7** (per-polygon shape): `northWest` AND `southEast` corners both present.
|
||||
- **Inv-8** (per-polygon invariant): `northWest.lat > southEast.lat` AND `northWest.lon < southEast.lon`.
|
||||
- **Inv-9**: Unknown root or nested fields → 400 (deserializer's `UnmappedMemberHandling.Disallow`).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: route mutation. No PUT / PATCH endpoint exists; routes are immutable post-creation.
|
||||
- **Not covered**: background processing (Flow F5) — Flow F5 docs cover the region enqueue, tile download, ZIP packaging, and `mapsReady` transition.
|
||||
- **Not covered**: response field renaming. The input/output naming asymmetry (`points[i].lat` request vs `points[i].latitude` response) is acknowledged in AC-10 advisory and tracked for a future major contract bump.
|
||||
- **Not covered**: the legacy `RouteValidator` in `SatelliteProvider.Services.RouteManagement` — it remains as defense-in-depth for direct service-layer callers; its checks are now redundant with this contract but a separate cleanup task should consolidate.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
|
||||
- **Minor (1.x.0)**: Adding an optional field consumers may safely ignore; relaxing a range; supporting a new geofence shape type alongside the existing bbox.
|
||||
- **Major (2.0.0)**: Renaming any request or response field; tightening any existing range; harmonizing the response point names to `lat`/`lon` (resolves AC-10); changing the `createTilesZip ⇔ requestMaps` cross-field rule semantics.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| happy-path-no-maps | full body with `requestMaps=false` | HTTP 200 + RouteResponse (mapsReady=false, no background processing) | AC-2 |
|
||||
| happy-path-with-maps | full body with `requestMaps=true` | HTTP 200; background F5 enqueues regions; `GET /api/satellite/route/{id}` shows `mapsReady=true` within ~20s for a 2-point 132m route at z=18 | AC-2 + existing RouteCreationTests |
|
||||
| empty-body | `""` | HTTP 400 | Rule 1 |
|
||||
| missing-id | body without `id` | HTTP 400 + `errors[id]` ("required") | Rule 2 (probe-confirmed gap) |
|
||||
| zero-guid-id | `"id":"00000000-..."` | HTTP 400 + `errors[id]` ("non-zero GUID") | Rule 2 |
|
||||
| empty-name | `"name":""` | HTTP 400 + `errors[name]` | Rule 3 |
|
||||
| description-too-long | `"description":<1001 chars>` | HTTP 400 + `errors[description]` | Rule 4 |
|
||||
| regionSize-out-of-range | `"regionSizeMeters":1000000` | HTTP 400 + `errors[regionSizeMeters]` | Rule 5 |
|
||||
| zoom-out-of-range | `"zoomLevel":30` | HTTP 400 + `errors[zoomLevel]` | Rule 6 |
|
||||
| points-too-few | 1-point array | HTTP 400 + `errors[points]` | Rule 7 (Flow F4 precondition) |
|
||||
| points-too-many | 501-point array | HTTP 400 + `errors[points]` | Rule 7 (cap) |
|
||||
| point-lat-out-of-range | `"points":[..., {"lat":91,..}]` | HTTP 400 + `errors["points[1].lat"]` | Rule 8 |
|
||||
| point-lon-out-of-range | `"points":[..., {"lat":..,"lon":181}]` | HTTP 400 + `errors["points[1].lon"]` | Rule 8 |
|
||||
| geofence-nw-not-north | NW.lat == SE.lat | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
|
||||
| geofence-nw-not-west | NW.lon == SE.lon | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
|
||||
| missing-requestMaps | body without `requestMaps` | HTTP 400 + `errors[requestMaps]` | Rule 10 |
|
||||
| createTilesZip-without-requestMaps | `"requestMaps":false,"createTilesZip":true` | HTTP 400 + `errors[createTilesZip]` | Rule 12 (cross-field) |
|
||||
| unknown-root-field | extra `"debug":"..."` key | HTTP 400 + `errors[debug]` | Rule 13 (`UnmappedMemberHandling.Disallow`) |
|
||||
| point-lat-type-mismatch | `"points":[{"lat":"fifty",..}, ..]` | HTTP 400 (nested JSON error) | Rule 14 (`GlobalExceptionHandler`) |
|
||||
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` |
|
||||
| idempotent-replay | re-POST same `id` | HTTP 200 (echoes existing resource) | `IdempotentPostTests` AC-2 |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/route`. Publishes the FluentValidation surface (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) + the 14 rules in AZ-809, including the probe-confirmed missing-id gap and the cross-field `createTilesZip ⇒ requestMaps` invariant. References `error-shape.md` v1.0.0, `region-request.md` v1.0.0 (F5 enqueue path), and Flows F4/F5 (cross-link). | autodev (Step 10, cycle 8) |
|
||||
@@ -0,0 +1,165 @@
|
||||
# Contract: tile-latlon
|
||||
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via TileDownloader (`SatelliteProvider.Services.TileDownloader`)
|
||||
**Producer task**: AZ-811 — `_docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md` (validator + this contract; renames query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency)
|
||||
**Consumer tasks**: dev / debug clients, future mission-planner UI single-tile-by-click flows; NOT currently consumed by `gps-denied-onboard` (the onboard side uses `GET /tiles/{z}/{x}/{y}` with pre-computed coords from inventory)
|
||||
**Version**: 1.0.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-22
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the HTTP contract for `GET /api/satellite/tiles/latlon` — the single-tile-by-coordinate read endpoint that converts a `(lat, lon, zoom)` triple to a slippy-map `(z, x, y)`, downloads the tile from Google Maps if not already cached, persists it, and returns the row's metadata as `DownloadTileResponse`. The actual tile bytes are served separately via `GET /tiles/{z}/{x}/{y}` once the caller has the resulting `(z, x, y)` (or the equivalent `tilePath` from the response).
|
||||
|
||||
This is the v1.0.0 of the contract — published alongside AZ-811's validator landing. There is no prior contract document; the producer-doc surface before AZ-811 was `modules/api_program.md::GetTileByLatLon Handler` only.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /api/satellite/tiles/latlon?lat=<float>&lon=<float>&zoom=<int>
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401.
|
||||
|
||||
## Shape
|
||||
|
||||
### Query parameters
|
||||
|
||||
```
|
||||
?lat=47.461747&lon=37.647063&zoom=18
|
||||
```
|
||||
|
||||
Per-parameter constraints:
|
||||
|
||||
| Param | Type | Required | Description | Constraints |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `lat` | number | yes | WGS84 latitude (decimal degrees). | `[-90.0, 90.0]`. |
|
||||
| `lon` | number | yes | WGS84 longitude (decimal degrees). | `[-180.0, 180.0]`. |
|
||||
| `zoom` | integer | yes | Slippy-map zoom level. | `[0, 22]`. |
|
||||
|
||||
Strict shape: any query-string parameter outside `{lat, lon, zoom}` is rejected by `RejectUnknownQueryParamsEndpointFilter` with HTTP 400 + the unknown key name in `errors`. This catches typos like `?latitude=` (pre-AZ-811 wire name) that ASP.NET model binding would otherwise silently ignore, and it also rejects hostile fingerprinting probes like `?debug=1&admin=true`.
|
||||
|
||||
**Required-field detection**: the bound DTO (`GetTileByLatLonQuery`) declares `lat` / `lon` / `zoom` as nullable (`double?`, `double?`, `int?`). Missing a query param therefore binds to `null` rather than throwing `BadHttpRequestException` from the framework binder — the request reaches the endpoint filters in all cases. `GetTileByLatLonQueryValidator` then enforces `NotNull` (chained `CascadeMode.Stop` ahead of the range rule) so a missing param surfaces as `errors[<paramName>]: ["\`<paramName>\` is required."]` exactly like any other validation failure. The handler dereferences `.Value` only after the validator filter has passed, guaranteed by the filter ordering.
|
||||
|
||||
### Response body
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "e228d1aa-25d4-556e-a72d-e0484756e165",
|
||||
"zoomLevel": 18,
|
||||
"latitude": 47.461747,
|
||||
"longitude": 37.647063,
|
||||
"tileSizeMeters": 39.84,
|
||||
"tileSizePixels": 256,
|
||||
"imageType": "jpg",
|
||||
"version": 1,
|
||||
"filePath": "tiles/18/158485/91707.jpg",
|
||||
"createdAt": "2026-05-22T12:34:56.789Z",
|
||||
"updatedAt": "2026-05-22T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-field semantics:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Deterministic UUIDv5 of the tile (`Uuidv5.TileNamespace, "{z}/{x}/{y}"`). |
|
||||
| `zoomLevel` | integer | Echoes the request `zoom` param. |
|
||||
| `latitude` | number | Tile centre latitude (server-resolved from slippy `(z,x,y)`; may differ from the request `lat` by up to half a tile). |
|
||||
| `longitude` | number | Tile centre longitude. |
|
||||
| `tileSizeMeters` | number | Approximate ground footprint of the tile at this zoom and latitude. |
|
||||
| `tileSizePixels` | integer | Fixed at 256 (slippy-map convention). |
|
||||
| `imageType` | string | Always `"jpg"`. |
|
||||
| `version` | integer | Tile row version (bumps on each refresh). |
|
||||
| `filePath` | string | Relative path under the tile cache root (`tiles/{z}/{x}/{y}.jpg`). |
|
||||
| `createdAt` | ISO-8601 UTC | Tile row creation timestamp. |
|
||||
| `updatedAt` | ISO-8601 UTC | Tile row last-modification timestamp. |
|
||||
|
||||
Response field names are intentionally LEGACY (`zoomLevel`, `latitude`, `longitude`) — only the request shape (query params) was renamed by AZ-811. The response is shared with `tile-storage.md` for caller consistency.
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Method | Path | Request | Response | Status codes |
|
||||
|--------|------|---------|----------|--------------|
|
||||
| `GET` | `/api/satellite/tiles/latlon` | query string `?lat&lon&zoom` | `DownloadTileResponse` | 200, 400, 401 |
|
||||
|
||||
## Error shape
|
||||
|
||||
All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies:
|
||||
|
||||
1. **`RejectUnknownQueryParamsEndpointFilter`** (envelope, runs first) — rejects any query key outside `{lat, lon, zoom}` with `errors[<paramName>]: ["Unknown query parameter ..."]`. Catches typos and hostile probes.
|
||||
2. **`GetTileByLatLonQueryValidator`** (FluentValidation, runs second) — range-checks `lat` / `lon` / `zoom` with `errors[<paramName>]: ["... must be between ..."]`.
|
||||
|
||||
Example body for a legacy-param-name failure (pre-AZ-811 wire format):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"Latitude": ["Unknown query parameter `Latitude`. Allowed: `lat`, `lon`, `zoom`."],
|
||||
"Longitude": ["Unknown query parameter `Longitude`. Allowed: `lat`, `lon`, `zoom`."],
|
||||
"ZoomLevel": ["Unknown query parameter `ZoomLevel`. Allowed: `lat`, `lon`, `zoom`."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example body for an out-of-range failure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"lat": ["`lat` must be between -90 and 90."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Inv-1**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`.
|
||||
- **Inv-2**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`.
|
||||
- **Inv-3**: `zoom ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8 and `region-request.md` Inv-5).
|
||||
- **Inv-4** (AZ-811 envelope filter): Any query-string key outside `{lat, lon, zoom}` → 400 with `errors[<key>]`. This is the novel envelope-strictness layer introduced by AZ-811; reuse the filter on future query-string endpoints by passing a fresh allowed-keys set.
|
||||
- **Inv-5** (deterministic mapping): `(lat, lon, zoom)` deterministically resolves to a single slippy-map `(z, x, y)` and therefore to a single `Uuidv5.TileNamespace`-derived tile `id`. Re-requesting the same triple returns the SAME `id` (cache hit if the tile already exists). Cross-referenced from `common_uuidv5.md`.
|
||||
- **Inv-6** (cache reuse): If the resolved `(z, x, y)` already has a row in `tiles`, no new Google-Maps fetch occurs; the existing row's metadata is returned. The handler delegates this decision to `ITileService.DownloadAndStoreSingleTileAsync`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Not covered**: tile body fetch. This endpoint returns metadata only. Bytes are served via `GET /tiles/{z}/{x}/{y}` (slippy-map URL).
|
||||
- **Not covered**: bulk download. Use `POST /api/satellite/tiles/inventory` for batch-lookup or `POST /api/satellite/request` for region pre-fetch.
|
||||
- **Not covered**: MGRS-based input. See `GET /api/satellite/tiles/mgrs` (stub, 501).
|
||||
- **Not covered**: backward-compatibility shim for `Latitude/Longitude/ZoomLevel` query param names. AZ-811 ships v1.0.0 directly with the post-rename names; pre-rename callers receive HTTP 400 from the envelope filter naming each unknown key. There is no transitional accept-both period.
|
||||
- **Not covered**: path-parameter validation on `GET /tiles/{z}/{x}/{y}` (the slippy-map body endpoint). That endpoint uses integer-binding which framework-validates the type but not the range; a separate task may add range checks if needed.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour.
|
||||
- **Minor (1.x.0)**: Adding an optional query param consumers may safely omit (e.g. `?format=png` if a non-jpg variant is later supported); adding an optional response field.
|
||||
- **Major (2.0.0)**: Changing any query-param name; tightening a range constraint that breaks current callers; removing `tileSizeMeters` from the response.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| Case | Input | Expected | Notes |
|
||||
|------|-------|----------|-------|
|
||||
| happy-path | `?lat=47.461747&lon=37.647063&zoom=18` | HTTP 200 + DownloadTileResponse | AC-2 |
|
||||
| missing-lat | `?lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]: ["\`lat\` is required."]` | Inv-1 (NotNull rule) |
|
||||
| lat-out-of-range | `?lat=91&lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]` | Inv-1 (range rule) |
|
||||
| lon-out-of-range | `?lat=47.461747&lon=181&zoom=18` | HTTP 400 + `errors["lon"]` | Inv-2 |
|
||||
| zoom-out-of-range | `?lat=47.461747&lon=37.647063&zoom=30` | HTTP 400 + `errors["zoom"]` | Inv-3 |
|
||||
| legacy-param-names | `?Latitude=47.46&Longitude=37.64&ZoomLevel=18` (pre-AZ-811 wire format) | HTTP 400 + `errors["Latitude","Longitude","ZoomLevel"]` | Inv-4 (AZ-811 envelope) |
|
||||
| hostile-extra-keys | `?lat=...&lon=...&zoom=18&debug=1&admin=true` | HTTP 400 + `errors["debug","admin"]` | Inv-4 |
|
||||
| typo-zooom | `?lat=...&lon=...&zooom=18` | HTTP 400 + `errors["zooom"]` | Inv-4 |
|
||||
| lat-type-mismatch | `?lat=fifty&lon=...&zoom=18` | HTTP 400 (model-binder JsonException-equivalent) | Wire-format failure |
|
||||
| cache-reuse | repeat happy-path | HTTP 200; same `id`; no new GET to Google Maps | Inv-5 + Inv-6 |
|
||||
| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
|
||||
|
||||
## Change Log
|
||||
|
||||
| Version | Date | Change | Author |
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-22 | Initial contract for `GET /api/satellite/tiles/latlon`. Publishes the post-AZ-811 OSM-convention query params (`lat`/`lon`/`zoom`) and the AZ-811 two-layer strict validation (envelope filter for unknown-keys + value-validator for range checks). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the bulk-lookup alternative. Pre-AZ-811 query-param names (`Latitude/Longitude/ZoomLevel`) are explicitly rejected by the envelope filter — no transitional shim. | autodev (Step 10, cycle 8) |
|
||||
@@ -4,9 +4,9 @@
|
||||
**Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md`
|
||||
**Extended by**: AZ-503 — `_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` (added optional `flightId` per-item field; per-flight on-disk path; deterministic `tileId`)
|
||||
**Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client
|
||||
**Version**: 1.1.0
|
||||
**Version**: 1.2.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-12
|
||||
**Last Updated**: 2026-05-23
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -50,6 +50,41 @@ Field names are camelCase. Property-name matching is case-insensitive on read.
|
||||
- `metadata.items.length` MUST NOT exceed `UavQualityConfig.MaxBatchSize` (default `100`). Oversize → HTTP 400.
|
||||
- The total request body size is capped at `MaxBatchSize × MaxBytes` by Kestrel's `MaxRequestBodySize` and the form `MultipartBodyLengthLimit`. Requests beyond this cap are rejected at the framework layer (HTTP 413/400).
|
||||
|
||||
## Metadata validation (14 rules, v1.2.0)
|
||||
|
||||
Before any file bytes are inspected by the Quality Gate below, the `metadata` envelope is run through a strict validator chain. This is the **metadata layer**; the **file layer** (see Quality Gate) is unchanged.
|
||||
|
||||
The validator is split into three composing layers and runs inside a custom multipart endpoint filter (`UavUploadValidationFilter`):
|
||||
|
||||
1. **Deserializer layer** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` on every non-optional axis of `UavTileBatchMetadataPayload` / `UavTileMetadata`. Catches missing-required, unknown fields (root and nested), JSON type mismatches, and malformed UUIDs. Errors surface under `errors["metadata"]`.
|
||||
2. **FluentValidation layer** — `UavTileBatchMetadataPayloadValidator` (envelope rules) and `UavTileMetadataValidator` (per-item rules). Errors surface under `errors["metadata.items"]` / `errors["metadata.items[i].<field>"]`.
|
||||
3. **Cross-field envelope rule** — `items.Count == files.Count`, evaluated in the filter after the FluentValidation result is clean. Errors surface under **both** `errors["metadata.items"]` AND `errors["files"]`.
|
||||
|
||||
Any failing rule short-circuits with HTTP 400 + RFC 7807 `ValidationProblemDetails` per `error-shape.md` v1.0.0. The body never reaches the Quality Gate or the persistence path on a metadata validation failure.
|
||||
|
||||
| # | Rule | Failure condition | Error path | Layer |
|
||||
|---|------|-------------------|------------|-------|
|
||||
| 1 | Multipart envelope present | Request `Content-Type` is not `multipart/form-data` | `errors["metadata"]` | filter |
|
||||
| 2 | `metadata` form field present | Multipart form has no part named `metadata` | `errors["metadata"]` | filter |
|
||||
| 3 | `metadata` parses as JSON | Malformed JSON body | `errors["metadata"]` | deserializer |
|
||||
| 4 | `items` required + non-empty | `items` missing OR `items: []` | `errors["metadata.items"]` | FluentValidation |
|
||||
| 5 | `items.Count` ≤ `UavQualityConfig.MaxBatchSize` | `items.Count > MaxBatchSize` (default 100) | `errors["metadata.items"]` | FluentValidation |
|
||||
| 6 | `items.Count == files.Count` | Per-item file count differs from metadata count | `errors["metadata.items"]` + `errors["files"]` | filter |
|
||||
| 7 | `latitude` ∈ [-90, +90] | Out of range | `errors["metadata.items[i].latitude"]` | FluentValidation |
|
||||
| 8 | `longitude` ∈ [-180, +180] | Out of range | `errors["metadata.items[i].longitude"]` | FluentValidation |
|
||||
| 9 | `tileZoom` ∈ [0, 22] | Out of range | `errors["metadata.items[i].tileZoom"]` | FluentValidation |
|
||||
| 10 | `tileSizeMeters` > 0 | Zero or negative | `errors["metadata.items[i].tileSizeMeters"]` | FluentValidation |
|
||||
| 11 | `capturedAt` within freshness window | `capturedAt > now + CapturedAtFutureSkewSeconds` OR `capturedAt < now - MaxAgeDays` | `errors["metadata.items[i].capturedAt"]` | FluentValidation |
|
||||
| 12 | `flightId` parses as UUID | Non-UUID string (`null`/missing is valid per AZ-503) | `errors["metadata"]` | deserializer |
|
||||
| 13 | Unknown fields rejected (root + nested) | Any field not declared on the DTO | `errors["metadata"]` | deserializer |
|
||||
| 14 | Type mismatch | e.g. `"latitude": "fifty"`, `"tileZoom": 18.5` | `errors["metadata"]` | deserializer |
|
||||
|
||||
### Relationship to the Quality Gate
|
||||
|
||||
The Quality Gate's Rule 4 (captured-at freshness) is preserved exactly as documented below. It runs **after** the metadata validator and provides defence-in-depth against handler callers that bypass the filter (unit tests of `IUavTileUploadHandler`, future internal call paths). Operators consuming the public API will see the metadata validator's verdict first.
|
||||
|
||||
The Quality Gate's Rules 1, 2, 3, 5 (file-level: format, size, dimensions, luminance) are unchanged and still produce per-item rejections via the existing HTTP 200 + `rejectReason` envelope — they have no metadata-validator equivalent.
|
||||
|
||||
## Quality Gate (5 rules)
|
||||
|
||||
Each item is evaluated in this fixed order. The **first** failing rule produces the reported reason; subsequent rules are not evaluated for that item.
|
||||
@@ -106,14 +141,31 @@ Adding a new code is a **minor** contract version bump per the Versioning Rules
|
||||
|
||||
### HTTP 400 — envelope error (RFC 7807 `application/problem+json`)
|
||||
|
||||
Returned when the request itself is malformed:
|
||||
Returned when the request itself is malformed. As of v1.2.0 every 400 body conforms to the shared `ValidationProblemDetails` shape in `error-shape.md` v1.0.0, with the `errors` map keys listed in the "Metadata validation" rule table above. Triggers include:
|
||||
|
||||
- `metadata` field absent, empty, or not valid JSON
|
||||
- `metadata.items` empty or null
|
||||
- `metadata.items.length` ≠ `files.length`
|
||||
- `metadata.items.length` > `MaxBatchSize`
|
||||
- Per-item `latitude`/`longitude`/`tileZoom`/`tileSizeMeters` out of declared range
|
||||
- Per-item `capturedAt` outside the freshness window
|
||||
- Unknown root or nested fields
|
||||
- Type mismatches and malformed UUIDs
|
||||
|
||||
The 5-rule per-item quality gate never produces a 400; per-item failures always surface in the HTTP 200 response array.
|
||||
Sample body:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"metadata.items[0].latitude": ["`latitude` must be between -90 and 90."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The 5-rule per-item quality gate never produces a 400; per-item file rejections always surface in the HTTP 200 response array.
|
||||
|
||||
### HTTP 401 — missing or invalid JWT (from AZ-487)
|
||||
|
||||
@@ -185,3 +237,4 @@ Each version bump requires updating the Change Log and notifying every consumer
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) |
|
||||
| 1.1.0 | 2026-05-12 | Minor bump for AZ-503: added optional `flightId` per-item metadata field (backward-compatible default `null`); `tileId` in the response is now a deterministic UUIDv5 derived from `(z, x, y, source, flightId)` instead of a random Guid; on-disk path adds a `{flightId or 'none'}` segment for per-flight evidence isolation. No reject-reason changes, no envelope changes, no permission changes. v1.0.0 clients omitting `flightId` keep working unchanged. | autodev (cycle 5 step 13) |
|
||||
| 1.2.0 | 2026-05-23 | Minor bump for AZ-810: added the **metadata validation** layer (14 rules) wired through a new `UavUploadValidationFilter` that runs BEFORE the per-item Quality Gate. Marks every non-optional metadata axis with `[JsonRequired]`; uses `UnmappedMemberHandling.Disallow` so unknown root/nested fields are rejected. HTTP 400 envelope-error body now matches the shared `ValidationProblemDetails` shape per `error-shape.md` v1.0.0. **Behavior change**: callers previously sending malformed metadata that silently coerced (e.g. `latitude: 0` for a missing field) now receive HTTP 400 instead of HTTP 200 + per-item rejection. No wire-format renames, no reject-reason changes, no permission changes. The Quality Gate (5 rules) is unchanged and continues as defence-in-depth. | autodev (cycle 8 batch 4) |
|
||||
|
||||
@@ -9,26 +9,39 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
| Method | Route | Handler | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| GET | `/tiles/{z}/{x}/{y}` | `ServeTile` | Slippy map tile server with in-memory caching. AZ-505 rewired the DB lookup to filter on `location_hash` (deterministic UUIDv5) so the read becomes an `Index Only Scan` against `tiles_leaflet_path`; the wire response is byte-identical to pre-AZ-505. |
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom |
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation<GetTileByLatLonQuery>()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. AZ-810 (cycle 8) added a strict **metadata-layer** validator that runs BEFORE the quality gate via the custom `UavUploadValidationFilter`: 14 rules covering required fields (`[JsonRequired]`), per-item ranges (`latitude`/`longitude`/`tileZoom`/`tileSizeMeters`/`capturedAt`), envelope alignment (`items.Count == files.Count`), and unknown-field / type-mismatch rejection via `UnmappedMemberHandling.Disallow`. Errors surface as RFC 7807 `ValidationProblemDetails` matching `error-shape.md` v1.0.0 with `errors["metadata.…"]` keys. Contract: `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
|
||||
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status |
|
||||
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points |
|
||||
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation<CreateRouteRequest>()`: non-zero `id`, name length ∈ \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point lat/lon range checks, per-polygon NW-of-SE invariants, and the `createTilesZip ⇒ requestMaps` cross-field rule. Deserializer-layer failures (missing `[JsonRequired]` axes, unknown fields, type mismatches) are caught by `GlobalExceptionHandler` and produce the same RFC 7807 envelope. Contract: `_docs/02_document/contracts/api/route-creation.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| GET | `/api/satellite/route/{id}` | `GetRoute` | Get route with all points |
|
||||
|
||||
### Local Records (defined in Program.cs)
|
||||
- `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs
|
||||
- `DownloadTileResponse` — tile download response
|
||||
- `RequestRegionRequest` — region request body
|
||||
- `ParameterDescriptionFilter` — Swagger operation filter
|
||||
- `ParameterDescriptionFilter` — Swagger operation filter (AZ-811 cycle 8 trimmed the obsolete `Latitude`/`Longitude`/`ZoomLevel` entries; the surviving `lat`/`lon`/`mgrs`/`squareSideMeters` keys still annotate query-string params)
|
||||
|
||||
### Api/Validators (AZ-795 epic, AZ-808/AZ-809/AZ-811 cycle 8)
|
||||
- `RejectUnknownQueryParamsEndpointFilter` — `IEndpointFilter` parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 `ValidationProblemDetails`. Apply BEFORE `WithValidation<T>()` so unknown-param errors precede range checks against the bound default value.
|
||||
- `GetTileByLatLonQueryValidator` — `AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel).
|
||||
- `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator<RequestRegionRequest>`. Post-deserialization business rules: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Required-field detection lives at the deserializer layer (`[JsonRequired]` + `UnmappedMemberHandling.Disallow`).
|
||||
- `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` (AZ-809 cycle 8) — three FluentValidation validators for the route-creation endpoint. The root validator chains `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` for per-point checks and `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")` for per-polygon checks. The `OverridePropertyName` on the geofences chain restores the full wire path (`geofences.polygons[i].northWest`) because FluentValidation's default name policy drops the parent on deep expressions like `req.Geofences!.Polygons`. `RoutePointValidator` uses `OverridePropertyName("lat"/"lon")` after each range rule so error keys match the wire format (`lat`/`lon`) rather than the camelCased C# names (`latitude`/`longitude`). The cross-field rule `createTilesZip ⇒ requestMaps` lives on the root via `Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip")`.
|
||||
- `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` (AZ-810 cycle 8) — FluentValidation validators for the UAV upload metadata envelope. Root validator runs `items` count rules (non-null, non-empty, ≤ `UavQualityConfig.MaxBatchSize`) then `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(...))` so per-item errors come out as `items[i].<field>` (then prefixed with `metadata.` by `UavUploadValidationFilter`). Per-item rules: `latitude` ∈ \[-90, 90\], `longitude` ∈ \[-180, 180\], `tileZoom` ∈ \[0, 22\], `tileSizeMeters` > 0, `capturedAt` within `\[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds\]`. `flightId` is intentionally NOT validated beyond JSON shape — AZ-503 anonymous-flight semantics require `null` to be valid, and malformed UUID strings are already rejected at the deserializer with a JsonException. The freshness check uses an injectable `TimeProvider` (defaults to `TimeProvider.System`) so unit tests can drive it with a fixed clock.
|
||||
- `UavUploadValidationFilter` (AZ-810 cycle 8) — endpoint filter for `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `WithValidation<T>()` JSON-body filter cannot bind directly; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` + `[JsonRequired]` from AZ-795 are honored), runs `IValidator<UavTileBatchMetadataPayload>` from DI, and enforces the cross-field `items.Count == files.Count` rule. Error-map keys from the per-item validator are prefixed with `metadata.` so paths surface to the caller as `errors["metadata.items[0].latitude"]`. Registered as a transient via `AddTransient<UavUploadValidationFilter>()` and wired on the endpoint with `.AddEndpointFilter<UavUploadValidationFilter>()`. The downstream `IUavTileUploadHandler` retains its own envelope checks as defence-in-depth (covers direct handler callers in unit tests).
|
||||
|
||||
### Api/DTOs (AZ-811 cycle 8)
|
||||
- `GetTileByLatLonQuery` — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes.
|
||||
|
||||
### Common/DTO (region API)
|
||||
- `RequestRegionRequest` — `POST /api/satellite/request` body. Moved out of Program.cs by AZ-369. Fields: `Id` (Guid), `Lat`/`Lon` (double, JSON `lat`/`lon` per AZ-812 cycle 8 OSM rename), `SizeMeters`, `ZoomLevel` (int, default 18), `StitchTiles` (bool, default false).
|
||||
|
||||
### Api/DTOs (AZ-488)
|
||||
- `UavTileBatchUploadRequest` — multipart envelope with `metadata` (JSON string) and `files` (`IFormFileCollection`)
|
||||
|
||||
### Common/DTO (AZ-488)
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape. AZ-810 cycle 8 added `[JsonRequired]` to every non-optional axis (`latitude`, `longitude`, `tileZoom`, `tileSizeMeters`, `capturedAt` on the per-item record; `items` on the envelope) so the deserializer rejects partial payloads with HTTP 400 before the FluentValidation + `IUavTileQualityGate` layers run. `flightId` stays optional per AZ-503 anonymous-flight semantics.
|
||||
- `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape
|
||||
- `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract
|
||||
|
||||
@@ -64,7 +77,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
11. **Kestrel HTTP/2 (AZ-505)**: `builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2))`. The dev listener is now `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated idempotently by `scripts/run-tests.sh` and bound via `ASPNETCORE_Kestrel__Certificates__Default__Path` / `__Password` in `docker-compose.yml`). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so HTTP/2-capable clients (browser Leaflet, `HttpClient` with `Version20` + `RequestVersionExact`, httpx `http2=True`) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code.
|
||||
12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler<GlobalExceptionHandler>()` register the uniform RFC 7807 error pipeline. `app.UseExceptionHandler()` (in the middleware chain) routes unhandled exceptions through `GlobalExceptionHandler`, which converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, JSON type mismatch) into `ValidationProblemDetails` with the same `errors[]` map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — `error-shape.md` v1.0.0 §"Two collaborating pieces of shared infrastructure".
|
||||
13. **Strict JSON parsing (AZ-795, cycle 7)**: `ConfigureHttpJsonOptions` sets `PropertyNamingPolicy = CamelCase`, `PropertyNameCaseInsensitive = true`, `UnmappedMemberHandling = Disallow`, and adds `JsonStringEnumConverter` with camelCase naming. `UnmappedMemberHandling.Disallow` is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos (`{"Z":12}` uppercase, `{"tileZoom":...}` post-rename) that no FluentValidation rule can see after deserialization.
|
||||
14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation<TileInventoryRequest>()` on the inventory MapPost — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure.
|
||||
14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`, AZ-808 `RegionRequestValidator`, AZ-809 `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator`, AZ-810 `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator`, AZ-811 `GetTileByLatLonQueryValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation<T>()` on the JSON-body endpoints — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure.
|
||||
15. **AZ-810 multipart validation filter (cycle 8)**: `AddTransient<UavUploadValidationFilter>()` registers the bespoke filter used by `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `.WithValidation<T>()` JSON-body filter cannot bind; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions`, runs the FluentValidation chain, and enforces the cross-field `items.Count == files.Count` envelope rule. Wired on the endpoint with `.AddEndpointFilter<UavUploadValidationFilter>()` between `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` and the metadata accept/produces annotations.
|
||||
|
||||
### Startup
|
||||
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
|
||||
@@ -87,10 +101,21 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
5. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
|
||||
|
||||
### GetTileByLatLon Handler
|
||||
Downloads a tile, persists it, returns metadata as `DownloadTileResponse`.
|
||||
Binds `[AsParameters] GetTileByLatLonQuery` (record with nullable `[FromQuery(Name="lat"|"lon"|"zoom")]` properties — see `Api/DTOs` for nullability rationale). Wire-format params are OSM-short `lat`/`lon`/`zoom` post-AZ-811. Strict validation is layered:
|
||||
1. `RejectUnknownQueryParamsEndpointFilter(new[] {"lat","lon","zoom"})` runs first — rejects any unexpected query key (e.g. `?latitude=` typo, or hostile fingerprinting probes) with RFC 7807 `ValidationProblemDetails` and an `errors[<paramName>]` entry.
|
||||
2. `WithValidation<GetTileByLatLonQuery>()` runs second — checks `NotNull` (missing param → `errors[<paramName>]: "\`<paramName>\` is required."`) and `InclusiveBetween` (`lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]). `CascadeMode.Stop` ensures null short-circuits the range check.
|
||||
3. Handler dereferences `query.Lat!.Value`, `query.Lon!.Value`, `query.Zoom!.Value` (validator guarantees non-null), delegates to `ITileService.DownloadAndStoreSingleTileAsync(lat, lon, zoom)`, and returns `DownloadTileResponse`.
|
||||
|
||||
The two filter layers produce identically-shaped ProblemDetails bodies. The `RejectUnknownQueryParamsEndpointFilter` is reusable — register it once per allowed-key set on any future query-string endpoint that needs the same shape-strictness.
|
||||
|
||||
### RequestRegion Handler
|
||||
Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`.
|
||||
AZ-808 (cycle 8) added strict pre-handler validation via `.WithValidation<RequestRegionRequest>()`: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Missing `[JsonRequired]` axes / unknown root fields are caught at the deserializer layer by `GlobalExceptionHandler`. Post-validation, delegates to `IRegionService.RequestRegionAsync`.
|
||||
|
||||
### CreateRoute Handler (AZ-809 cycle 8)
|
||||
Pre-handler validation via `.WithValidation<CreateRouteRequest>()`. Layered defence:
|
||||
1. **Deserializer layer (System.Text.Json + `GlobalExceptionHandler`)** — `[JsonRequired]` markers on `CreateRouteRequest.{Id, Name, RegionSizeMeters, ZoomLevel, Points, RequestMaps, CreateTilesZip}`, on `RoutePoint.{Latitude, Longitude}`, on `Geofences.Polygons`, on `GeofencePolygon.{NorthWest, SouthEast}`, and on `GeoPoint.{Lat, Lon}` catch missing-field payloads; `UnmappedMemberHandling.Disallow` catches unknown root + nested fields; type mismatches surface as `JsonException`. All three surface as HTTP 400 + `ValidationProblemDetails`.
|
||||
2. **Validator layer (`CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator`)** — non-zero `id`, name/description length caps, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point range checks (error keys `points[i].lat` / `points[i].lon`), per-polygon corner ranges + `NW.Lat > SE.Lat` + `NW.Lon < SE.Lon` invariants (error keys `geofences.polygons[i].northWest`), and the `createTilesZip ⇒ requestMaps` cross-field rule.
|
||||
3. **Handler** — receives a fully-validated `CreateRouteRequest` and delegates to `IRouteService.CreateRouteAsync`. The route service's own legacy `RouteValidator` (in `SatelliteProvider.Services.RouteManagement`) still runs as a defence-in-depth backstop — its checks are now strictly weaker than the validator-layer rules; tracked as an advisory clean-up in `route-creation.md`. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
|
||||
|
||||
### UploadUavTileBatch Handler (AZ-488)
|
||||
Buffers each `IFormFile` into memory, packages them as `UavUploadFile` records (filename, content-type, bytes), and delegates to `IUavTileUploadHandler.HandleAsync`. Envelope-level errors (mismatched batch, oversized batch, malformed metadata) are surfaced as HTTP 400 ProblemDetails; per-item rejects are returned in the HTTP 200 response payload. The endpoint is protected by `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` so 401 (no token) and 403 (no `GPS` permission) are returned before the handler runs.
|
||||
|
||||
@@ -6,9 +6,9 @@ Data transfer objects used across all layers — API requests/responses, inter-s
|
||||
## Public Interface
|
||||
|
||||
### GeoPoint
|
||||
Geographic coordinate with tolerance-based equality.
|
||||
- `Lat` (double): latitude, JSON property `"lat"`
|
||||
- `Lon` (double): longitude, JSON property `"lon"`
|
||||
Geographic coordinate with tolerance-based equality. AZ-809 (cycle 8) marked both axes `[JsonRequired]` so a polygon corner missing either axis is rejected at the deserializer layer.
|
||||
- `Lat` (double, `[JsonRequired]`, JSON: `"lat"`)
|
||||
- `Lon` (double, `[JsonRequired]`, JSON: `"lon"`)
|
||||
- Constructor: `GeoPoint()`, `GeoPoint(double lat, double lon)`
|
||||
- Equality: two points are equal if both coordinates differ by less than `0.00005` (PRECISION_TOLERANCE)
|
||||
- Operator overloads: `==`, `!=`
|
||||
@@ -33,8 +33,14 @@ Metadata about a stored tile (mirrors `TileEntity` but without DB-specific conce
|
||||
- `Version` (int?), `FilePath` (string)
|
||||
- `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RequestRegionRequest (renamed by AZ-812 cycle 8 — OSM convention)
|
||||
API request body for `POST /api/satellite/request` (region enqueue). Defined in `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`. Moved out of `Program.cs` by AZ-369.
|
||||
- `Id` (Guid), `Lat` (double, JSON: `"lat"`), `Lon` (double, JSON: `"lon"`), `SizeMeters` (double)
|
||||
- `ZoomLevel` (int, default 18), `StitchTiles` (bool, default false)
|
||||
- AZ-812 renamed C# props `Latitude/Longitude` → `Lat/Lon` and added `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]` to make the wire format unambiguous. With `JsonSerializerOptions.UnmappedMemberHandling.Disallow` active (AZ-795), the old `latitude`/`longitude` wire shape now returns HTTP 400.
|
||||
|
||||
### RegionRequest
|
||||
Queue message for async region processing.
|
||||
Internal queue message for async region processing (not a wire-format DTO — exchanged between the API handler and `RegionProcessingService` background worker via `IRegionRequestQueue`). Distinct from `RequestRegionRequest` above; intentionally kept on `Latitude`/`Longitude` because the queue is in-process only.
|
||||
- `Id` (Guid), `Latitude`, `Longitude` (double), `SizeMeters` (double)
|
||||
- `ZoomLevel` (int), `StitchTiles` (bool)
|
||||
|
||||
@@ -44,20 +50,27 @@ Response DTO for region status queries.
|
||||
- `TilesDownloaded`, `TilesReused` (int), `CreatedAt`, `UpdatedAt` (DateTime)
|
||||
|
||||
### RoutePoint
|
||||
Input point in a route creation request.
|
||||
- `Latitude` (double, JSON: `"lat"`), `Longitude` (double, JSON: `"lon"`)
|
||||
Input point in a route creation request. AZ-809 (cycle 8) marked both axes `[JsonRequired]` so the System.Text.Json deserializer rejects missing-axis payloads with HTTP 400 + `ValidationProblemDetails` via `GlobalExceptionHandler` BEFORE the FluentValidation layer runs.
|
||||
- `Latitude` (double, `[JsonRequired]`, JSON: `"lat"`)
|
||||
- `Longitude` (double, `[JsonRequired]`, JSON: `"lon"`)
|
||||
|
||||
### RoutePointDto
|
||||
Output point in a route response (includes computed fields).
|
||||
- `Latitude`, `Longitude` (double), `PointType` (string: "start"/"end"/"action"/"intermediate")
|
||||
- `SequenceNumber`, `SegmentIndex` (int), `DistanceFromPrevious` (double?)
|
||||
- **Naming asymmetry**: input wire uses short OSM `lat`/`lon` (`RoutePoint`); response wire uses long `latitude`/`longitude` (`RoutePointDto`). Pre-existing — AZ-809 documented but did not change this. Tracked as a follow-up advisory in `_docs/02_document/contracts/api/route-creation.md`.
|
||||
|
||||
### CreateRouteRequest
|
||||
API request body for route creation.
|
||||
- `Id` (Guid), `Name` (string), `Description` (string?)
|
||||
- `RegionSizeMeters` (double), `ZoomLevel` (int)
|
||||
- `Points` (List\<RoutePoint\>), `Geofences` (Geofences?)
|
||||
- `RequestMaps` (bool), `CreateTilesZip` (bool)
|
||||
API request body for route creation. AZ-809 (cycle 8) added `[JsonRequired]` to every non-optional axis so missing fields are caught at the deserializer layer (uniform with AZ-808 region-request and AZ-795 inventory).
|
||||
- `Id` (Guid, `[JsonRequired]`) — caller-supplied idempotency key; non-zero GUID
|
||||
- `Name` (string, `[JsonRequired]`) — length \[1, 200\]
|
||||
- `Description` (string?) — optional, length ≤ 1000 when present
|
||||
- `RegionSizeMeters` (double, `[JsonRequired]`) — \[100, 10000\]
|
||||
- `ZoomLevel` (int, `[JsonRequired]`) — \[0, 22\] slippy-map range
|
||||
- `Points` (List\<RoutePoint\>, `[JsonRequired]`) — count ∈ \[2, 500\]
|
||||
- `Geofences` (Geofences?) — optional; when present, each polygon validated
|
||||
- `RequestMaps` (bool, `[JsonRequired]`) — no default; missing → 400
|
||||
- `CreateTilesZip` (bool, `[JsonRequired]`) — no default; cross-field invariant requires `requestMaps=true` when `true`
|
||||
|
||||
### RouteResponse
|
||||
API response for route queries.
|
||||
@@ -65,12 +78,14 @@ API response for route queries.
|
||||
- `MapsReady` (bool), `TilesZipPath` (string?)
|
||||
|
||||
### GeofencePolygon
|
||||
Axis-aligned bounding box defined by NW and SE corners.
|
||||
- `NorthWest` (GeoPoint?), `SouthEast` (GeoPoint?)
|
||||
Axis-aligned bounding box defined by NW and SE corners. AZ-809 (cycle 8) marked both corners `[JsonRequired]` so a partially-specified polygon (just `northWest`, no `southEast`, or vice-versa) is rejected at the deserializer layer.
|
||||
- `NorthWest` (GeoPoint?, `[JsonRequired]`, JSON: `"northWest"`)
|
||||
- `SouthEast` (GeoPoint?, `[JsonRequired]`, JSON: `"southEast"`)
|
||||
- Cross-corner invariants (enforced by `GeofencePolygonValidator`): `NW.Lat > SE.Lat` (NW is north-of SE) and `NW.Lon < SE.Lon` (NW is west-of SE). Equal corners fail both invariants with `errors["geofences.polygons[i].northWest"]`.
|
||||
|
||||
### Geofences
|
||||
Container for multiple geofence polygons.
|
||||
- `Polygons` (List\<GeofencePolygon\>)
|
||||
Container for multiple geofence polygons. AZ-809 (cycle 8) marked `Polygons` `[JsonRequired]` so an empty `geofences: {}` envelope is rejected.
|
||||
- `Polygons` (List\<GeofencePolygon\>, `[JsonRequired]`, JSON: `"polygons"`) — at least 1 polygon when `geofences` is present (validator rule, not deserializer rule).
|
||||
|
||||
### UavTileMetadata (added AZ-488, extended AZ-503)
|
||||
Per-tile metadata payload inside a UAV batch upload (`POST /api/satellite/upload`). Indexed-correlated with the multipart `IFormFileCollection`.
|
||||
|
||||
@@ -35,7 +35,7 @@ All members are static on `Uuidv5`:
|
||||
| `"18/12345/23456"` | `38b26f49-a966-5121-aaf4-9cc476f57869` |
|
||||
| `"18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000"` | `e228d1aa-25d4-556e-a72d-e0484756e165` |
|
||||
|
||||
The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` returns `tileId = e228d1aa-25d4-556e-a72d-e0484756e165` because `(47.461747, 37.647063)` maps to slippy `(z=18, x=158485, y=91707)` — and the integration test asserts that exact value.
|
||||
The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18` returns `tileId = e228d1aa-25d4-556e-a72d-e0484756e165` because `(47.461747, 37.647063)` maps to slippy `(z=18, x=158485, y=91707)` — and the integration test asserts that exact value. (AZ-811 cycle 8 renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency.)
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
|
||||
### Description
|
||||
|
||||
Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata.
|
||||
Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata. The wire-format contract is `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Valid latitude, longitude, and zoom level provided
|
||||
- Query params `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]. Any unknown query key (e.g. legacy `?Latitude=` typo) is rejected by `RejectUnknownQueryParamsEndpointFilter` (AZ-811 cycle 8) with HTTP 400. Range checks via `GetTileByLatLonQueryValidator`.
|
||||
- Google Maps session token configured
|
||||
|
||||
### Sequence Diagram
|
||||
@@ -80,11 +80,11 @@ sequenceDiagram
|
||||
|
||||
### Description
|
||||
|
||||
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing.
|
||||
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing. The wire-format contract is `_docs/02_document/contracts/api/region-request.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- Valid region parameters (lat, lon, size_meters, zoom_level)
|
||||
- Valid region parameters: non-zero `id` (UUID), `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `sizeMeters` ∈ [100, 10000], `zoomLevel` ∈ [0, 22], explicit `stitchTiles` (bool, no default). Enforced by `RegionRequestValidator` + `[JsonRequired]` at the API edge (AZ-808 cycle 8).
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
@@ -177,12 +177,13 @@ sequenceDiagram
|
||||
|
||||
### Description
|
||||
|
||||
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set.
|
||||
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set. The wire-format contract is `_docs/02_document/contracts/api/route-creation.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0.
|
||||
|
||||
### Preconditions
|
||||
|
||||
- At least 2 waypoints provided
|
||||
- Valid geofence polygons (if provided)
|
||||
- JWT in `Authorization: Bearer <token>` validates against the API's signing key, issuer, and audience (`.RequireAuthorization()`).
|
||||
- Request body deserializes successfully: all `[JsonRequired]` axes present (`id`, `name`, `regionSizeMeters`, `zoomLevel`, `points`, `requestMaps`, `createTilesZip`, plus per-point `lat`/`lon`, per-polygon `northWest`/`southEast`, per-corner `lat`/`lon`, `geofences.polygons` when `geofences` present); no unknown root or nested fields (`UnmappedMemberHandling.Disallow`).
|
||||
- `CreateRouteRequestValidator` rules pass: non-zero `id`, name length \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with each point's lat/lon in range, per-polygon corner ranges + NW-of-SE invariants, `createTilesZip ⇒ requestMaps`.
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
@@ -190,26 +191,33 @@ Client submits a route (ordered waypoints + optional geofence polygons). The ser
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WebApi
|
||||
participant ValidationFilter
|
||||
participant RouteService
|
||||
participant RouteRepo
|
||||
participant GeoUtils
|
||||
|
||||
Client->>WebApi: POST /api/satellite/route {points, geofences, options}
|
||||
WebApi->>RouteService: CreateRoute(request)
|
||||
RouteService->>GeoUtils: Interpolate points between waypoints
|
||||
GeoUtils-->>RouteService: All points (original + intermediate)
|
||||
RouteService->>RouteRepo: InsertRoute(RouteEntity)
|
||||
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
|
||||
RouteService-->>WebApi: RouteResponse
|
||||
WebApi-->>Client: 200 OK {route_id, total_points, total_distance}
|
||||
Client->>WebApi: POST /api/satellite/route {id, name, points, geofences?, ...}
|
||||
WebApi->>ValidationFilter: .WithValidation<CreateRouteRequest>()
|
||||
alt validation fails
|
||||
ValidationFilter-->>Client: 400 ValidationProblemDetails (errors{path→msg})
|
||||
else validation passes
|
||||
WebApi->>RouteService: CreateRoute(request)
|
||||
RouteService->>GeoUtils: Interpolate points between waypoints
|
||||
GeoUtils-->>RouteService: All points (original + intermediate)
|
||||
RouteService->>RouteRepo: InsertRoute(RouteEntity)
|
||||
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
|
||||
RouteService-->>WebApi: RouteResponse
|
||||
WebApi-->>Client: 200 OK {id, totalPoints, totalDistanceMeters, ...}
|
||||
end
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Error | Where | Detection | Recovery |
|
||||
|-------|-------|-----------|----------|
|
||||
| Invalid points (< 2) | Validation | Count check | Return 400 |
|
||||
| DB insert failure | Persist step | Exception | Return 500 |
|
||||
| Missing `[JsonRequired]` axis / unknown field / type mismatch | Deserializer | `JsonException` → `GlobalExceptionHandler` | Return 400 `ValidationProblemDetails` (per `error-shape.md` v1.0.0) |
|
||||
| Validator rule violation (range, count, cross-field) | `ValidationEndpointFilter<CreateRouteRequest>` | `CreateRouteRequestValidator` + nested `RoutePointValidator` / `GeofencePolygonValidator` | Return 400 with `errors{path→msg}` map |
|
||||
| DB insert failure | Persist step | Exception | Return 500 (sanitised body + correlationId per AZ-353) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## BT-01: Single Tile Download
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18
|
||||
**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
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
## 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"
|
||||
**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
|
||||
|
||||
@@ -86,33 +86,36 @@
|
||||
|
||||
## BT-N01: Invalid Coordinates (out of range)
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=91&Longitude=181&ZoomLevel=18
|
||||
**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?Latitude=47.46&Longitude=37.64&ZoomLevel=25
|
||||
**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
|
||||
**Expected**: Validation error
|
||||
**Pass criterion**: HTTP 400 or validation error message
|
||||
**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)
|
||||
## 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**: Validation error
|
||||
**Pass criterion**: Error message mentioning coordinates cannot be (0,0)
|
||||
**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
|
||||
## BT-N05: Geofence with Inverted Corners — superseded by AZ-809
|
||||
|
||||
**Trigger**: POST /api/satellite/route with geofence NW.lat < SE.lat
|
||||
**Expected**: Validation error
|
||||
**Pass criterion**: Error message about northWest latitude > southEast latitude
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +166,7 @@ All Cycle-2 UAV scenarios run with a JWT containing `permissions: ["GPS"]` (per
|
||||
|
||||
## 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.
|
||||
**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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## SEC-01: SQL Injection via Coordinate Parameters
|
||||
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?Latitude=1;DROP TABLE tiles--&Longitude=1&ZoomLevel=18
|
||||
**Trigger**: GET /api/satellite/tiles/latlon?lat=1;DROP TABLE tiles--&lon=1&zoom=18
|
||||
**Expected**: Request rejected or treated as invalid parameter
|
||||
**Pass criterion**: HTTP 400 or parameter parsing error; no database damage; tiles table intact
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
## SEC-04: Malformed JSON in Route Request
|
||||
|
||||
**Trigger**: POST /api/satellite/route with invalid JSON body
|
||||
**Expected**: Parse error returned
|
||||
**Pass criterion**: HTTP 400; error message indicates parsing failure; no crash
|
||||
**Trigger**: POST /api/satellite/route with invalid JSON body (truncated `{` or non-JSON text).
|
||||
**Expected**: HTTP 400 + RFC 7807 `ProblemDetails`. Post-AZ-809 (cycle 8) the failure surfaces via `GlobalExceptionHandler`'s `JsonException` branch (System.Text.Json `JsonReaderException` → `BadHttpRequestException` → 400). No stack trace leaks; correlationId present per AZ-353.
|
||||
**Pass criterion**: HTTP 400; `Content-Type: application/problem+json`; body matches `error-shape.md` v1.0.0; no internal exception type or stack frame in `detail`.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,7 +32,7 @@ The pre-AZ-487 assumption "no authentication" is superseded by these scenarios.
|
||||
|
||||
## SEC-05: Anonymous Request to Any Authenticated Endpoint Returns 401
|
||||
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header.
|
||||
**Trigger**: GET `/api/satellite/tiles/latlon?lat=...&lon=...&zoom=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header.
|
||||
**Precondition**: API running with `JWT_SECRET` configured.
|
||||
**Expected**: HTTP 401 Unauthorized; `WWW-Authenticate: Bearer` header present; response body does not leak validation internals.
|
||||
**Pass criterion**: status == 401 AND `WWW-Authenticate` header starts with `Bearer`.
|
||||
|
||||
@@ -127,6 +127,31 @@ Adopted into satellite-provider cycle 7 with the recommended ordering: shared va
|
||||
| AZ-795 | Strict input validation across all public endpoints (FluentValidation + ProblemDetails) — **Epic with shared-infra ship** | — (children gated on shared infra landing first) | — (epic; shared-infra estimate 5–8 pts; per-endpoint children ~3 pts each) | Done — shared infra shipped (cycle 7); future per-endpoint child tasks open |
|
||||
| AZ-796 | Strict validation for inventory endpoint (POST /api/satellite/tiles/inventory) | AZ-795 (HARD — shared infra); coordinate with AZ-794 | 3 | Done (cycle 7) |
|
||||
|
||||
### Step 9 cycle 8 — Per-endpoint validation children of AZ-795 (cross-repo follow-up)
|
||||
|
||||
Source: cross-repo request from `gps-denied-onboard` agent (2026-05-22). After AZ-795 shipped the shared infra (FluentValidation + GlobalExceptionHandler + UnmappedMemberHandling.Disallow + ValidationEndpointFilter) and AZ-796 shipped the inventory endpoint as the first concrete child, four additional public endpoints remain silent-coercion-permissive: `POST /api/satellite/request` (region onboarding), `POST /api/satellite/route` (route creation), `POST /api/satellite/upload` (UAV metadata layer; the file-level quality gate from AZ-488 stays), `GET /api/satellite/tiles/latlon` (single-tile download). All four are queued here as cycle-8 candidates, each mirroring the AZ-796 reference implementation pattern with endpoint-specific adaptations.
|
||||
|
||||
**Cross-repo context**: AZ-808 + AZ-809 are blocking dependencies for gps-denied-onboard AZ-777 Phase 2 (Derkachi reference tile catalog seeding). AZ-810 is a defense-in-depth tightening for the existing AZ-488 UAV upload path. AZ-811 is the smallest item, included for completeness of the per-endpoint surface.
|
||||
|
||||
| Task | Title | Depends On | Points | Status |
|
||||
|------|-------|-----------|--------|--------|
|
||||
| AZ-808 | Strict validation for region-request endpoint (POST /api/satellite/request) | AZ-795 (HARD — shared infra); AZ-796 (reference); AZ-812 (HARD — ships first in cycle 8 per /autodev step 10 user decision 2026-05-22; AZ-808 spec field references rewrite from `latitude`/`longitude` → `lat`/`lon` before validator implementation starts) | 3 | To Do |
|
||||
| AZ-809 | Strict validation for route-creation endpoint (POST /api/satellite/route) | AZ-795 (HARD); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract reference) | 5 | To Do |
|
||||
| AZ-810 | Strict validation for UAV upload metadata (POST /api/satellite/upload) | AZ-795 (HARD); AZ-796 (single-DTO reference); AZ-809 (nested per-item reference); AZ-488 (must remain green); AZ-503 (flightId semantics) | 5 | To Do |
|
||||
| AZ-811 | Strict validation for lat/lon tile GET endpoint (GET /api/satellite/tiles/latlon) | AZ-795 (HARD); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract reference) | 2 | To Do |
|
||||
|
||||
**Spec amendments (2026-05-22, post-probe)**: AZ-808 and AZ-809 specs were amended after a gps-denied-onboard black-box probe of the running producer surfaced two real silent-coercion gaps and one input/output naming asymmetry. Notable spec changes: (1) AZ-808 rule count 8 → 9 (added `Id` non-zero-Guid rule); (2) AZ-809 rule count 13 → 14 (added `Id` non-zero-Guid rule); (3) AZ-809 added AC-10 advisory documenting the input/output point-naming asymmetry on `RouteResponse.points[]`; (4) AZ-808 added field-naming coordination section pointing at AZ-812. Story-point estimates unchanged; the new rules were already implicit in the AZ-795 epic's mandate.
|
||||
|
||||
### Step 9 cycle 8b — Region API field-name harmonization (cross-repo follow-up)
|
||||
|
||||
Source: cross-repo request from `gps-denied-onboard` agent (2026-05-22). After the AZ-777 Phase 2 black-box probe of the Region API, the consumer attempted `{"lat":..,"lon":..}` against `POST /api/satellite/request` and received HTTP 400 with `UnmappedMemberHandling.Disallow` rejecting the unknown fields. The producer DTO uses verbose `latitude`/`longitude`, which is the **only** OSM-deviating coord convention left in the public API surface: the inventory endpoint already uses `z/x/y` (per AZ-794), the Route endpoint's `RoutePoint`/`GeoPoint` already use `lat`/`lon` (per existing `[JsonPropertyName]`), and the slippy-map URL uses `z/x/y`. AZ-812 closes the inconsistency by renaming Region to match.
|
||||
|
||||
This is a separate cycle (8b) because it's a **wire-format rename** (mirror of AZ-794) rather than a validator add (mirror of AZ-796). The two operations are surgically distinct even though they touch the same DTO.
|
||||
|
||||
| Task | Title | Depends On | Points | Status |
|
||||
|------|-------|-----------|--------|--------|
|
||||
| AZ-812 | satellite-provider: rename `RequestRegionRequest.{Latitude, Longitude}` → `{Lat, Lon}` (OSM convention) + harmonize cross-endpoint | — (coordinate release ordering with AZ-808) | 3 | To Do |
|
||||
|
||||
## Execution Order
|
||||
|
||||
### Step 6
|
||||
@@ -193,6 +218,24 @@ Adopted into cycle 7. Ordering:
|
||||
3. AZ-796 (inventory validator) — first per-endpoint child; serves as reference implementation for sibling per-endpoint child tasks.
|
||||
4. Sibling per-endpoint child tasks under AZ-795 — added by parent-suite team as they enumerate the surface from `/swagger/v1/swagger.json` (out of cycle 7 scope; future cycles).
|
||||
|
||||
### Step 9 cycle 8 (AZ-808 / AZ-809 / AZ-810 / AZ-811 / AZ-812)
|
||||
|
||||
Ordering decision recorded 2026-05-22 (`/autodev` Step 10 dirty-tree resolution): **Option 1 (AZ-812 first, then AZ-808 against final lat/lon names)** — chosen to avoid AZ-808 double-migration on contract doc + integration tests. AZ-809, AZ-810, AZ-811 are independent of AZ-812 (their DTOs already use OSM short form).
|
||||
|
||||
Execution order:
|
||||
|
||||
1. AZ-812 (3 SP) — Region DTO rename `Latitude/Longitude` → `Lat/Lon`. Ships first; AZ-808 depends on its outcome. Own batch (wire-format change is atomic; independent rollback target).
|
||||
2. AZ-811 (2 SP) — smallest validator unblocker; closes the simplest endpoint and validates the query-param filter pattern for any future query-string endpoints. Independent of AZ-812.
|
||||
3. AZ-808 (3 SP) — region-request validator written against post-rename `lat/lon`; unblocks gps-denied-onboard AZ-777 Phase 2 bbox-based seeding path. Hard-depends on AZ-812.
|
||||
4. AZ-809 (5 SP) — route-creation validator; unblocks gps-denied-onboard AZ-777 Phase 2 route-based (preferred) seeding path. Independent of AZ-812.
|
||||
5. AZ-810 (5 SP) — UAV upload metadata validator; defense-in-depth for AZ-488 multipart endpoint. Independent of AZ-812.
|
||||
|
||||
Parent-suite team may reorder steps 2–5 based on consumer priorities; step 1 (AZ-812) must remain first.
|
||||
|
||||
### Step 9 cycle 8b (AZ-812 — folded into cycle 8 ordering above)
|
||||
|
||||
Originally tracked as a separate cycle 8b because AZ-812 is a wire-format rename (mirror of AZ-794) rather than a validator add (mirror of AZ-796). After the /autodev Step 10 ordering decision above, cycle 8b folds into cycle 8 as step 1 of the execution order. Section retained for traceability — the cycle-8b table entry remains the authoritative spec marker for AZ-812.
|
||||
|
||||
## Total Effort
|
||||
|
||||
Step 6: 6 tasks, 17 story points
|
||||
@@ -205,6 +248,8 @@ Step 9 cycle 4: 1 task created (AZ-500 = 5 pts)
|
||||
Step 9 cycle 5: 3 tasks tracked (AZ-503 = 3 pts foundation-half, AZ-504 = 1 pt, AZ-505 = 3 pts split-off-deferred) — 4 pts committed to cycle 5, 3 pts deferred to cycle 6
|
||||
Step 9 cycle 6: 1 task scheduled (AZ-505 = 3 pts) — consumed from cycle-5 deferral
|
||||
Step 9 cycle 7: 3 tasks adopted (AZ-794 = 3 pts rename, AZ-795 = epic with 5–8 pts shared-infra ship, AZ-796 = 3 pts first per-endpoint child) — total ~11–14 pts (over the 2–5 pts/cycle preference; AZ-795's shared-infra ship is the heavy item). Origin: gps-denied-onboard AZ-777 Phase 1 Jetson probe (2026-05-22). Sibling per-endpoint child tasks under AZ-795 to be added in future cycles as the parent-suite team enumerates the endpoint surface.
|
||||
Step 9 cycle 8: 5 tasks queued (AZ-812 = 3 pts Region DTO rename, AZ-808 = 3 pts region validator, AZ-809 = 5 pts route, AZ-810 = 5 pts UAV upload metadata, AZ-811 = 2 pts lat/lon GET) — total 18 pts across 4 per-endpoint AZ-795 children + 1 OSM-naming harmonization. Origin: cross-repo request from gps-denied-onboard agent (2026-05-22) for completeness of validation surface after AZ-795/796 landed, plus AZ-777 Phase 2 black-box probe surfacing the Region DTO as the lone OSM hold-out. Ordering: AZ-812 first (per /autodev Step 10 user decision), then AZ-808/809/810/811 (independent of each other modulo AZ-812). AZ-808 and AZ-809 specs amended 2026-05-22 post-probe to add `Id` non-zero-Guid rule + Route AC-10 input/output naming asymmetry advisory.
|
||||
Step 9 cycle 8b: folded into cycle 8 as step 1 (AZ-812). Section retained in dependency table for traceability.
|
||||
|
||||
## Coverage Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Strict validation for region-request endpoint (POST /api/satellite/request)
|
||||
|
||||
**Task**: AZ-808_region_endpoint_validation
|
||||
**Name**: Strict validation for region-request endpoint
|
||||
**Description**: Add FluentValidation-backed strict input validation to `POST /api/satellite/request` (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Second concrete child of AZ-795; reuses the shared infra wired in cycle 7.
|
||||
**Complexity**: 3 points (7 validation rules — was 6 before the 2026-05-22 probe added the `Id` rule)
|
||||
**Dependencies**: AZ-795 (HARD — shared infra already landed in cycle 7); AZ-796 (reference implementation pattern); AZ-812 (field-naming coordination — see below)
|
||||
**Component**: SatelliteProvider.Api/Validators + SatelliteProvider.Common (RequestRegionRequest DTO)
|
||||
**Tracker**: AZ-808 (https://denyspopov.atlassian.net/browse/AZ-808)
|
||||
**Epic**: AZ-795 — Strict input validation across all public endpoints
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — consumer needs this endpoint to seed Derkachi reference tile catalog; black-box probe surfaced concrete silent-coercion behavior
|
||||
|
||||
## Scope
|
||||
|
||||
Add FluentValidation-backed strict input validation to `POST /api/satellite/request` (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
|
||||
|
||||
Originating discovery: AZ-777 Phase 2 (gps-denied-onboard) — the consumer needs to call this endpoint to seed the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior that this task fixes (see *Probe-confirmed gaps* below).
|
||||
|
||||
Jira AZ-808 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Probe-confirmed gaps (2026-05-22)
|
||||
|
||||
A black-box probe of the running producer captured these concrete behaviors that this task must close:
|
||||
|
||||
1. **`Id` silently coerces to zero-Guid when omitted.** Body `{"latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}` (no `id`) returned HTTP 200 with `"id":"00000000-0000-0000-0000-000000000000"` and `status:queued`. The `[Required]` DataAnnotation on `RequestRegionRequest.Id` is NOT enforced — the deserializer just yields the default Guid. This is the same silent-coercion class that motivated AZ-795. Validator must reject zero-Guid + missing-Id with the same RFC 7807 shape as the inventory validator.
|
||||
2. **`UnmappedMemberHandling.Disallow` IS active for this endpoint.** Sending the wrong field name (`{"lat":49.94,...}`) returned HTTP 400 with the proper ValidationProblemDetails shape: `{"errors":{"lat":["The JSON property 'lat' could not be mapped to any .NET member contained in type 'SatelliteProvider.Common.DTO.RequestRegionRequest'."]}}`. So rule 8 (unknown-field rejection) is already covered by AZ-795 cycle-7 shared infra; this task only needs to verify it stays active after wiring `WithValidation<T>()`.
|
||||
3. **Happy path works end-to-end.** With the correct shape `{"id":"<guid>","latitude":..,"longitude":..,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}`: HTTP 200 + regionId + 9 tiles downloaded from Google Maps + accessible via `GET /tiles/{z}/{x}/{y}` (13 KB JPEG verified). Validator must NOT regress this path.
|
||||
|
||||
## Field-naming coordination with AZ-812
|
||||
|
||||
This spec uses the **current wire format** (`latitude`, `longitude`) because that's what the DTO ships today and that's what the validator must reject malformed values for. **AZ-812** (mirror of AZ-794 for inventory) is filed to rename these to `lat`/`lon` for OSM-style consistency across all satellite-provider endpoints.
|
||||
|
||||
If AZ-812 lands **before** this task, rewrite all field references in this spec from `latitude`/`longitude` to `lat`/`lon` before implementing. If AZ-812 lands **after** this task, AZ-812 must also update the validator + contract doc + integration tests. Pick the ordering during planning to avoid double migration.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
`POST /api/satellite/request`
|
||||
|
||||
Current wire format (per `RequestRegionRequest.cs`, probe-confirmed 2026-05-22):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<guid>",
|
||||
"latitude": 50.10,
|
||||
"longitude": 36.10,
|
||||
"sizeMeters": 5000,
|
||||
"zoomLevel": 18,
|
||||
"stitchTiles": false
|
||||
}
|
||||
```
|
||||
|
||||
Response: HTTP 200 with `RegionStatusResponse` (id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt). Async — the actual tile downloads happen in the background via `RegionProcessingService` (Flow F3). Caller polls `GET /api/satellite/region/{id}` until `status:completed`.
|
||||
|
||||
## Required validations
|
||||
|
||||
1. **Body present** — null/empty body → 400 (`errors.$`).
|
||||
2. **`id` required, non-zero Guid** — NEW (probe-confirmed gap). Missing or `00000000-...` → 400 with `errors.id`. Use `RuleFor(x => x.Id).NotEmpty()` (FluentValidation's `NotEmpty()` rejects default-Guid).
|
||||
3. **`latitude` required** — double, in `[-90.0, 90.0]`. Out-of-range or missing → 400 with `errors.latitude`.
|
||||
4. **`longitude` required** — double, in `[-180.0, 180.0]`. Out-of-range or missing → 400 with `errors.longitude`.
|
||||
5. **`sizeMeters` required** — double, in `[100.0, 10000.0]` (matches current inline check in `RequestRegion Handler` per `api_program.md`). Out-of-range or missing → 400 with `errors.sizeMeters`.
|
||||
6. **`zoomLevel` required** — int, in `[0, 22]` (align with `TileCoordValidator` slippy-map range used by AZ-796 for the inventory endpoint). Out-of-range or missing → 400 with `errors.zoomLevel`.
|
||||
7. **`stitchTiles` required** — bool. Missing → 400 with `errors.stitchTiles` (no defaulting to `false` — force the caller to declare intent).
|
||||
8. **Unknown root fields rejected** — already covered by AZ-795's `UnmappedMemberHandling.Disallow` (probe-confirmed active). Verify it stays active after wiring `WithValidation<T>()`.
|
||||
9. **Type mismatch** — e.g. `"latitude": "fifty"` → 400 with `errors.latitude` ("could not be parsed"). Already covered by AZ-795's `GlobalExceptionHandler`; verify it triggers for this endpoint.
|
||||
|
||||
## Implementation pattern (mirror AZ-796)
|
||||
|
||||
1. New file: `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` — `AbstractValidator<RequestRegionRequest>` with rules 2–7.
|
||||
2. Mark `RequestRegionRequest` props with `[JsonRequired]` (replacing or supplementing the existing `[Required]` DataAnnotation — the latter is not enforced by `System.Text.Json`, as the probe confirmed). Apply to `Id`, `Latitude`, `Longitude`, `SizeMeters`, `ZoomLevel`, `StitchTiles`.
|
||||
3. Add `.WithValidation<RequestRegionRequest>()` to the `MapPost("/api/satellite/request", ...)` chain in `Program.cs`.
|
||||
4. Unit tests: `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — one test per `RuleFor(...)` (≥ 6 methods covering id, latitude, longitude, sizeMeters, zoomLevel, stitchTiles).
|
||||
5. Integration tests: `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` (new file) — ≥ 9 methods (1 happy + 1 per failure-mode AC — including missing-id reproducing the probe's silent-coercion case).
|
||||
6. Manual probe: `scripts/probe_region_validation.sh` (mirrors `scripts/probe_inventory_validation.sh` from AZ-796). MUST include the missing-id test case.
|
||||
|
||||
## New contract doc
|
||||
|
||||
Create `_docs/02_document/contracts/api/region-request.md` v1.0.0. The region endpoint has **no formal contract** today (only `system-flows.md` F2 + module docs). The contract doc must cover:
|
||||
|
||||
- Endpoint, auth, request body, response body (use the actual `RegionStatusResponse` shape: id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt), error shape (reference `error-shape.md` v1.0.0).
|
||||
- Invariants (one regionId per request; client-provided non-zero Id; size cap; async semantics — caller must poll `GET /api/satellite/region/{id}`).
|
||||
- Test cases mirroring the validator rules (same `Case | Input | Expected | Notes` table format as `tile-inventory.md` v2.0.0). MUST include the missing-id case.
|
||||
- Cross-link to `RegionStatus` flow (F3) and the consumer-facing inventory contract (`tile-inventory.md` — callers seed via region, then read via inventory).
|
||||
- Reference to AZ-812 (field-naming follow-up).
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
|
||||
- **AZ-796 (inventory)**: reference implementation — copy the validator + integration-test layout 1:1.
|
||||
- **AZ-812 (region field rename)**: hard coordination on field names. See *Field-naming coordination with AZ-812* above.
|
||||
- **AZ-777 (gps-denied-onboard)**: consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND the contract doc exists. Consumer has black-box-probed the endpoint and can use it today, but silent-coercion bugs make Phase 2 fragile until validation is in place.
|
||||
- Sibling validation tasks created in the same batch: **AZ-809** (route), **AZ-810** (UAV upload metadata), **AZ-811** (lat/lon GET).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**AC-1**: Each of the 9 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision; unrelated rules NOT in the `errors` map).
|
||||
|
||||
**AC-2**: Happy path unchanged — a valid body still returns HTTP 200 + `RegionStatusResponse`; background processing still runs; the probe's 9-tile Derkachi case (`{"id":"<guid>","latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}`) still completes in under 10 seconds.
|
||||
|
||||
**AC-3**: `RegionRequestValidator` lives in its own file under `SatelliteProvider.Api/Validators/` and is unit-tested (≥ 1 test per `RuleFor`).
|
||||
|
||||
**AC-4**: `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` covers happy + 8+ failure modes with full ValidationProblemDetails assertion (use the existing `ProblemDetailsAssertions` helper from AZ-795). MUST include `Post_WithMissingId_ReturnsBadRequest` (reproducing the 2026-05-22 probe's silent-coercion case).
|
||||
|
||||
**AC-5**: `_docs/02_document/contracts/api/region-request.md` v1.0.0 created and published.
|
||||
|
||||
**AC-6**: `_docs/02_document/system-flows.md` F2 updated to reference the new contract doc + error shape.
|
||||
|
||||
**AC-7**: OpenAPI spec marks `RequestRegionRequest` fields `required`, declares ranges, and documents the 400 response (matches AZ-796 Swashbuckle annotations).
|
||||
|
||||
**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Region API's processing semantics (Flow F3 — `RegionProcessingService`) — validation lives at the API layer only.
|
||||
- Any change to `IRegionService.RequestRegionAsync` signature beyond accepting the validated DTO.
|
||||
- `GET /api/satellite/region/{id}` status endpoint (separate task if path-parameter validation needed; current Guid binding is framework-handled).
|
||||
- The field-name rename (`Latitude/Longitude` → `Lat/Lon`) — handled by AZ-812.
|
||||
- Performance — validation overhead is negligible vs the async enqueue + Google Maps round-trip.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking behavior change** — any consumer today omitting `id` (silently getting zero-Guid) or sending malformed values will start getting 400. Known consumer set: gps-denied-onboard (currently uses correct body shape with id, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team.
|
||||
- No regression in any existing `RegionRequestTests.cs` happy-path coverage.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-808: https://denyspopov.atlassian.net/browse/AZ-808
|
||||
- Parent Epic: AZ-795 (shared infra; error-shape contract)
|
||||
- Reference implementation: AZ-796 (inventory endpoint)
|
||||
- Coordination: AZ-812 (region field-name rename to OSM convention)
|
||||
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as next in line)
|
||||
- Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (2026-05-22 black-box probe)
|
||||
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)
|
||||
@@ -0,0 +1,156 @@
|
||||
# Strict validation for route-creation endpoint (POST /api/satellite/route)
|
||||
|
||||
**Task**: AZ-809_route_endpoint_validation
|
||||
**Name**: Strict validation for route-creation endpoint
|
||||
**Description**: Add FluentValidation-backed strict input validation to `POST /api/satellite/route` (route creation — client submits ordered waypoints + optional geofence polygons; producer interpolates intermediate points every ≈ 200 m and — if `requestMaps=true` — enqueues a region request per route point for async tile backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Third concrete child of AZ-795; reuses the shared infra wired in cycle 7.
|
||||
**Complexity**: 5 points (14 rules — was 13 before the 2026-05-22 probe added the `Id` rule; 3 validator classes; cross-field constraint; new contract doc)
|
||||
**Dependencies**: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract pattern, same batch)
|
||||
**Component**: SatelliteProvider.Api/Validators + SatelliteProvider.Common (CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint DTOs)
|
||||
**Tracker**: AZ-809 (https://denyspopov.atlassian.net/browse/AZ-809)
|
||||
**Epic**: AZ-795 — Strict input validation across all public endpoints
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — route-based seeding is the consumer's preferred imagery seeding path; black-box probe surfaced silent-coercion + input/output naming asymmetry
|
||||
|
||||
## Scope
|
||||
|
||||
Add FluentValidation-backed strict input validation to `POST /api/satellite/route`. Reject malformed payloads with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
|
||||
|
||||
Originating discovery: AZ-777 Phase 2 (gps-denied-onboard) — the consumer's preferred imagery seeding path is route-based (flight-track waypoints) rather than bbox-based, so this endpoint is the primary integration target for the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior and an input/output naming asymmetry (see *Probe-confirmed gaps* below).
|
||||
|
||||
Jira AZ-809 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Probe-confirmed gaps (2026-05-22)
|
||||
|
||||
A black-box probe of the running producer captured these concrete behaviors:
|
||||
|
||||
1. **`Id` silently coerces to zero-Guid when omitted.** Same gap as the Region endpoint (AZ-808). `CreateRouteRequest.Id` has no `[Required]` and no `[JsonRequired]`, so the deserializer yields zero-Guid. Validator must reject missing/zero Id.
|
||||
2. **Happy path works end-to-end** for both `requestMaps:false` (route storage only, instant) and `requestMaps:true` (route + background tile backfill, ~15s for a 2-point 132m route at z=18). Validator must NOT regress.
|
||||
3. **Input/output naming asymmetry on points** (new finding). Input `points: [{"lat":..,"lon":..}]` (OSM short form, per `[JsonPropertyName("lat")]` on `RoutePoint`). But the **response** echoes points as `{"latitude":..,"longitude":..,"pointType":..,"sequenceNumber":..,"segmentIndex":..,"distanceFromPrevious":..}`. This is a DTO round-trip inconsistency on the same object type. NOT in scope for this validation task, but surfaced as **AC-10** (advisory) so the parent-suite team can decide whether to file a follow-up.
|
||||
4. **`UnmappedMemberHandling.Disallow` is active globally** (verified via AZ-808 probe), so unknown-field rejection (rule 13) will work out-of-the-box once `WithValidation<T>()` is wired.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
`POST /api/satellite/route`
|
||||
|
||||
Current wire format (per `CreateRouteRequest`, probe-confirmed 2026-05-22):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"name": "derkachi-flight-1",
|
||||
"description": "AZ-777 Phase 2 seed route",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{ "northWest": { "lat": 50.15, "lon": 36.05 },
|
||||
"southEast": { "lat": 50.05, "lon": 36.15 } }
|
||||
]
|
||||
},
|
||||
"requestMaps": true,
|
||||
"createTilesZip": false
|
||||
}
|
||||
```
|
||||
|
||||
Response (current, probe-confirmed): HTTP 200 with `RouteResponse` (id, name, description, regionSizeMeters, zoomLevel, totalDistanceMeters, totalPoints, points[], requestMaps, mapsReady, csvFilePath, summaryFilePath, stitchedImagePath, tilesZipPath, createdAt, updatedAt). Note response uses `latitude`/`longitude` for echoed points — see AC-10.
|
||||
|
||||
Background processing per Flow F5 if `requestMaps=true`; client polls `GET /api/satellite/route/{id}` until `mapsReady:true`.
|
||||
|
||||
## Required validations
|
||||
|
||||
1. **Body present** — null/empty body → 400 (`errors.$`).
|
||||
2. **`id` required, non-zero Guid** — NEW (probe-confirmed gap, same as AZ-808). Missing or `00000000-...` → 400 with `errors.id`.
|
||||
3. **`name` required** — non-empty string, length `[1, 200]`. Missing/empty → 400 with `errors.name`.
|
||||
4. **`description` optional** — if present, length `[0, 1000]`. Over cap → 400 with `errors.description`.
|
||||
5. **`regionSizeMeters` required** — double, in `[100.0, 10000.0]` (align with region endpoint). Out-of-range or missing → 400 with `errors.regionSizeMeters`.
|
||||
6. **`zoomLevel` required** — int, in `[0, 22]` (align with `TileCoordValidator`). Out-of-range or missing → 400 with `errors.zoomLevel`.
|
||||
7. **`points` required, non-empty** — at least **2 entries** (current `Flow F4` precondition), at most **500 entries** (cap to prevent runaway region-enqueue — confirm cap with parent-suite team). Below 2 or above 500 → 400 with `errors.points`.
|
||||
8. **Per-point**: `lat` required, double, in `[-90.0, 90.0]`; `lon` required, double, in `[-180.0, 180.0]`. Missing/out-of-range → 400 with `errors.points[i].lat` or `.lon`.
|
||||
9. **`geofences` optional** — if present:
|
||||
- `polygons` required, non-empty.
|
||||
- Per-polygon: `northWest` + `southEast` both required, each with valid `lat`/`lon`.
|
||||
- Cross-field invariant: `northWest.lat > southEast.lat` AND `northWest.lon < southEast.lon` (i.e. NW is genuinely north-of and west-of SE).
|
||||
- Violations → 400 with `errors.geofences.polygons[i].<field>`.
|
||||
10. **`requestMaps` required** — bool. Missing → 400 with `errors.requestMaps`.
|
||||
11. **`createTilesZip` required** — bool. Missing → 400 with `errors.createTilesZip`.
|
||||
12. **Cross-field constraint**: `createTilesZip == true` implies `requestMaps == true` (can't zip what wasn't downloaded). Violation → 400 with `errors.$` or `errors.createTilesZip`.
|
||||
13. **Unknown root or nested fields rejected** — covered by AZ-795's `UnmappedMemberHandling.Disallow` (probe-confirmed active globally via AZ-808). Any unknown field at any nesting level → 400 with `errors.<path>` ("could not be mapped to any .NET member").
|
||||
14. **Type mismatch** — e.g. `"lat": "fifty"` at any nesting level → 400 with `errors.<path>`. Covered by AZ-795's `GlobalExceptionHandler`.
|
||||
|
||||
## Implementation pattern (mirror AZ-796, extended for nesting)
|
||||
|
||||
1. New files (all under `SatelliteProvider.Api/Validators/`):
|
||||
- `CreateRouteRequestValidator.cs` — root validator with rules 2–7, 10–12.
|
||||
- `RoutePointValidator.cs` — per-point validator (rule 8); invoked via `RuleForEach(x => x.Points).SetValidator(new RoutePointValidator())`.
|
||||
- `GeofencePolygonValidator.cs` — per-polygon validator (rule 9); invoked via `RuleForEach(x => x.Geofences.Polygons).SetValidator(new GeofencePolygonValidator())` (guarded by `When(x => x.Geofences != null)`).
|
||||
2. Mark required props on `CreateRouteRequest`, `RoutePoint`, `Geofences`, `GeofencePolygon`, `GeoPoint` with `[JsonRequired]` per the cycle-7 `TileCoord` pattern. Pay special attention to `Id` (probe confirmed it's not enforced today).
|
||||
3. Add `.WithValidation<CreateRouteRequest>()` to the `MapPost("/api/satellite/route", ...)` chain.
|
||||
4. Unit tests: `SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs` + `RoutePointValidatorTests.cs` + `GeofencePolygonValidatorTests.cs` (≥ 13 test methods total — one per `RuleFor`/`RuleForEach` chain; new id-rule method must reproduce the probe's missing-id case).
|
||||
5. Integration tests: `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` (new file) — ≥ 14 methods (1 happy + 1 per failure-mode AC).
|
||||
6. Manual probe: `scripts/probe_route_validation.sh`. MUST include missing-id, NW-southeast-inverted polygon, points-too-few, createTilesZip-without-requestMaps.
|
||||
|
||||
## New contract doc
|
||||
|
||||
Create `_docs/02_document/contracts/api/route-creation.md` v1.0.0. Like the region endpoint, this has **no formal contract** today. Cover:
|
||||
|
||||
- Endpoint, auth, request body (with nested DTO recursion), response body (`RouteResponse` shape — acknowledge the input/output point-naming asymmetry; reference AC-10 advisory), error shape (reference `error-shape.md` v1.0.0).
|
||||
- Invariants (client-provided non-zero Id; one routeId per request; min 2 points; max 500 points; polygon NW>SE; cross-field createTilesZip implies requestMaps).
|
||||
- Test cases table (same format as `tile-inventory.md` v2.0.0). MUST include missing-id, geofence NW/SE inversion, createTilesZip cross-field, points-too-few cases.
|
||||
- Cross-link to Flow F4 (Route Creation) + Flow F5 (Route Map Processing background) + `region-request.md` (referenced by F5 enqueue path).
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
|
||||
- **AZ-796 (inventory)**: reference for single-DTO validator pattern.
|
||||
- **AZ-808 (region)**: reference for endpoint without prior contract doc (same precondition: must create new `region-request.md`); coordinate field-name conventions across the two contracts. The naming inconsistency `RequestRegionRequest.sizeMeters` vs `CreateRouteRequest.regionSizeMeters` (same concept, different names) is flagged in AC-9.
|
||||
- **AZ-812 (region field rename)**: tangentially related — AZ-812 is bringing Region into the lat/lon convention that Route already uses. No direct dependency on this task.
|
||||
- **AZ-777 (gps-denied-onboard)**: consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND `route-creation.md` exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**AC-1**: Each of the 14 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).
|
||||
|
||||
**AC-2**: Happy path unchanged — a valid body still returns HTTP 200 + `RouteResponse`; background F5 processing still runs when `requestMaps=true`; probe's 2-point 132m route still completes (`mapsReady:true`) in under 20 seconds.
|
||||
|
||||
**AC-3**: All three validators (`CreateRouteRequestValidator`, `RoutePointValidator`, `GeofencePolygonValidator`) live in their own files under `SatelliteProvider.Api/Validators/` and are unit-tested (≥ 1 test per `RuleFor`/`RuleForEach` chain, ≥ 13 methods total).
|
||||
|
||||
**AC-4**: `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` covers happy + 13+ failure modes with full ValidationProblemDetails assertion. MUST include `Post_WithMissingId_ReturnsBadRequest` (reproducing the 2026-05-22 probe's silent-coercion case).
|
||||
|
||||
**AC-5**: `_docs/02_document/contracts/api/route-creation.md` v1.0.0 created and published.
|
||||
|
||||
**AC-6**: `_docs/02_document/system-flows.md` F4 + F5 updated to reference the new contract doc + error shape.
|
||||
|
||||
**AC-7**: OpenAPI spec marks all required fields at every nesting level, declares ranges, and documents the 400 response.
|
||||
|
||||
**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.
|
||||
|
||||
**AC-9** (advisory — surface in PR, parent-suite to decide): the inconsistency `RequestRegionRequest.sizeMeters` vs `CreateRouteRequest.regionSizeMeters` is named differently for the same concept. Either keep the discrepancy and document why, or harmonize to a single name in a follow-up MAJOR contract bump for both.
|
||||
|
||||
**AC-10** (advisory — surface in PR, parent-suite to decide): the **input/output point-naming asymmetry** on this endpoint (input `points: [{"lat":..,"lon":..}]`, response `points: [{"latitude":..,"longitude":..}]` for the same `RoutePoint` round-trip) is a DTO inconsistency. Probe-confirmed 2026-05-22. Either keep + document, or file a follow-up to harmonize.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Route processing semantics (Flow F5 background, ZIP creation, point-in-polygon geofence filtering) — validation lives at the API layer only.
|
||||
- `GET /api/satellite/route/{id}` status endpoint (separate task if needed; Guid binding is framework-handled).
|
||||
- Performance — nested validation overhead is negligible vs interpolation + background region enqueue.
|
||||
- Route interpolation algorithm — unchanged.
|
||||
- Input/output point-naming asymmetry fix — surfaced as AC-10 advisory only.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking behavior change** — callers today omitting `id` (silently getting zero-Guid) or sending malformed nested bodies will start getting 400. Known consumer set: gps-denied-onboard (uses correct body shape with id and lat/lon points, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team.
|
||||
- No regression in any existing `RouteCreationTests.cs` happy-path coverage.
|
||||
- Cross-field constraint (rule 12) requires custom `When/Otherwise` or a top-level `Must()` rule — FluentValidation 12.0.0 supports both; pick the more readable one.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-809: https://denyspopov.atlassian.net/browse/AZ-809
|
||||
- Parent Epic: AZ-795
|
||||
- Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
|
||||
- Tangentially related: AZ-812 (region field rename to OSM)
|
||||
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as a per-endpoint child of AZ-795)
|
||||
- Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (route-based seeding is the consumer's preferred path; 2026-05-22 black-box probe surfaced silent-coercion + naming asymmetry)
|
||||
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)
|
||||
@@ -0,0 +1,147 @@
|
||||
# Strict validation for UAV upload metadata (POST /api/satellite/upload)
|
||||
|
||||
**Task**: AZ-810_upload_metadata_validation
|
||||
**Name**: Strict validation for UAV upload metadata
|
||||
**Description**: Add FluentValidation-backed strict input validation to the metadata DTO layer of `POST /api/satellite/upload` (UAV batch upload, AZ-488). Reject malformed metadata JSON envelopes with RFC 7807 ValidationProblemDetails (HTTP 400). Fourth concrete child of AZ-795; reuses the shared infra wired in cycle 7. The file-level quality checks (size, luminance, age, future-skew) remain in scope of the existing `IUavTileQualityGate`.
|
||||
**Complexity**: 5 points (multipart envelope requires custom filter, 14 rules, two validator classes, MINOR contract bump, defense-in-depth with existing UavTileQualityGate)
|
||||
**Dependencies**: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-809 (nested per-item reference); AZ-488 (original endpoint — must remain green); AZ-503 (flightId semantics)
|
||||
**Component**: SatelliteProvider.Api/Validators + SatelliteProvider.Common (UavTileBatchMetadataPayload, UavTileMetadata DTOs)
|
||||
**Tracker**: AZ-810 (https://denyspopov.atlassian.net/browse/AZ-810)
|
||||
**Epic**: AZ-795 — Strict input validation across all public endpoints
|
||||
**Originating ticket**: AZ-795 cycle-7 retro (explicitly names this endpoint as a remaining per-endpoint child)
|
||||
|
||||
## Scope
|
||||
|
||||
Add FluentValidation-backed strict input validation to the **metadata DTO** layer of `POST /api/satellite/upload`. Reject malformed `metadata` JSON envelopes with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
|
||||
|
||||
**Important scope boundary**: this task is about the **metadata envelope** — `UavTileBatchMetadataPayload` and its per-item `UavTileMetadata` payloads. The **file-level** quality checks (size, luminance variance, age, future-skew) are already enforced by the existing `IUavTileQualityGate` per AZ-488 and remain in scope of that gate. The DTO validator runs **before** the quality gate (per-item bytes inspection) so malformed metadata can short-circuit without ever touching the file bytes.
|
||||
|
||||
Originating discovery: AZ-795 cycle-7 retro — the metadata DTO is explicitly named as a remaining gap ("already partly validated by `UavTileQualityGate`, but the metadata layer is a separate validator").
|
||||
|
||||
Jira AZ-810 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
`POST /api/satellite/upload` (multipart/form-data, auth: `RequiresGpsPermission` policy on top of JWT bearer)
|
||||
|
||||
Multipart envelope:
|
||||
- `metadata` form field — JSON string deserialized to `UavTileBatchMetadataPayload`.
|
||||
- `files` form field — `IFormFileCollection`, one entry per metadata item, position-correlated.
|
||||
|
||||
`UavTileBatchMetadataPayload` (current shape, per `modules/common_dtos.md`):
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"lat": 50.10,
|
||||
"lon": 36.10,
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 19.10925707,
|
||||
"capturedAt": "2026-05-22T08:00:00Z",
|
||||
"flightId": "a1b2c3d4-..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response (current per AZ-488): HTTP 200 `{items: [UavTileUploadResultItem[]]}` even on per-item failures. Envelope-level failures (oversize batch, malformed metadata, mismatched batch) are HTTP 400 ProblemDetails. **This task tightens the "malformed metadata" path.**
|
||||
|
||||
## Required validations
|
||||
|
||||
### Envelope-level
|
||||
|
||||
1. **Multipart envelope present** — missing multipart form → framework-level 400 (unchanged).
|
||||
2. **`metadata` field present** — missing form field → 400 with `errors.metadata` ("required").
|
||||
3. **`metadata` parses as JSON** — malformed JSON → 400 with `errors.metadata` ("could not be parsed as JSON"). Covered by AZ-795's `GlobalExceptionHandler` once metadata binding routes through `JsonSerializerOptions`.
|
||||
4. **`metadata.items` required, non-empty** — missing or `[]` → 400 with `errors.metadata.items`.
|
||||
5. **`metadata.items.length` ≤ `UavQualityConfig.MaxBatchSize`** — over cap → 400 with `errors.metadata.items`. (Existing framework limit handles oversize via `KestrelServerOptions.Limits.MaxRequestBodySize` at the byte layer; this rule guards the item count specifically.)
|
||||
6. **`metadata.items.length` == `files.length`** — envelope alignment per AZ-488. Already detected by the upload handler; surface via ValidationProblemDetails for consistency with sibling endpoints → 400 with `errors.metadata` + `errors.files`.
|
||||
|
||||
### Per-item (under `metadata.items[i]`)
|
||||
|
||||
7. **`lat` required** — double, in `[-90.0, 90.0]`. Missing/out-of-range → 400 with `errors.metadata.items[i].lat`.
|
||||
8. **`lon` required** — double, in `[-180.0, 180.0]`. Missing/out-of-range → 400 with `errors.metadata.items[i].lon`.
|
||||
9. **`tileZoom` required** — int, in `[0, 22]` (align with `TileCoordValidator`). Missing/out-of-range → 400 with `errors.metadata.items[i].tileZoom`.
|
||||
10. **`tileSizeMeters` required** — double, `> 0.0`. Missing/non-positive → 400 with `errors.metadata.items[i].tileSizeMeters`. (Tighter range can be added if parent-suite team has a documented expected range; for now just guard `> 0`.)
|
||||
11. **`capturedAt` required** — ISO-8601 UTC `DateTime`. Must satisfy AZ-488 Rule 4 freshness window: `capturedAt ≤ now + UavQualityConfig.CapturedAtFutureSkewSeconds` AND `capturedAt ≥ now - UavQualityConfig.MaxAgeDays`. Missing/out-of-window → 400 with `errors.metadata.items[i].capturedAt`. (Equivalent to AZ-488 Rule 4 but at the API layer; the existing UavTileQualityGate still enforces the same rule for defense-in-depth.)
|
||||
12. **`flightId` optional** — if present, must be valid `Guid` (RFC 4122). Malformed UUID → 400 with `errors.metadata.items[i].flightId`. (Null/missing is valid — anonymous-flight semantics per AZ-503.)
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
13. **Unknown fields rejected at root or any nesting level of `metadata`** — covered by AZ-795's `UnmappedMemberHandling.Disallow`. Any unknown field at root or under `items[i]` → 400 with `errors.metadata.<path>` ("could not be mapped to any .NET member").
|
||||
14. **Type mismatch** — e.g. `"lat": "fifty"` or `"tileZoom": 18.5` (non-integer double for int) → 400 with `errors.metadata.<path>`. Covered by AZ-795's `GlobalExceptionHandler`.
|
||||
|
||||
## Implementation pattern (mirror AZ-796, extended for multipart + per-item)
|
||||
|
||||
1. New files (all under `SatelliteProvider.Api/Validators/`):
|
||||
- `UavTileBatchMetadataPayloadValidator.cs` — root validator with rules 4–6.
|
||||
- `UavTileMetadataValidator.cs` — per-item validator (rules 7–12); invoked via `RuleForEach(x => x.Items).SetValidator(new UavTileMetadataValidator(uavQualityConfig))`.
|
||||
2. Mark required props on `UavTileBatchMetadataPayload` + `UavTileMetadata` with `[JsonRequired]` per the cycle-7 `TileCoord` pattern.
|
||||
3. Wire the validator into the multipart handler in `Program.cs` (the `UploadUavTileBatch` endpoint) — likely a custom endpoint filter that:
|
||||
a. Reads the `metadata` form field.
|
||||
b. Deserializes via the strict `JsonSerializerOptions` (already configured by AZ-795).
|
||||
c. Resolves `IValidator<UavTileBatchMetadataPayload>` from DI and runs it.
|
||||
d. Returns `Results.ValidationProblem` on failure.
|
||||
|
||||
This is a more involved wiring than AZ-796 (which uses the bog-standard `.WithValidation<T>()` filter for pure JSON bodies). Document the new filter in `_docs/02_document/modules/api_program.md`.
|
||||
4. Unit tests: `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` + `UavTileMetadataValidatorTests.cs` (≥ 11 test methods total — one per `RuleFor`/`RuleForEach` chain).
|
||||
5. Integration tests: `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` (new file) — ≥ 13 methods (1 happy + 1 per failure-mode AC + envelope alignment regression).
|
||||
6. Manual probe: `scripts/probe_upload_validation.sh` — multipart `curl` against each failure mode.
|
||||
|
||||
## Update existing contract doc
|
||||
|
||||
Bump `_docs/02_document/contracts/api/uav-tile-upload.md` from v1.1.0 → v1.2.0 (MINOR). The contract doc exists; this task adds the validation rules + error shape reference. Do NOT change the wire format (no rename like AZ-794); MINOR is correct.
|
||||
|
||||
Add a new section: "Validation rules (AZ-810)" that enumerates the 14 rules and references `error-shape.md` v1.0.0.
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
|
||||
- **AZ-796 (inventory)**: reference for single-DTO pattern.
|
||||
- **AZ-809 (route)**: reference for nested per-item validator pattern (RuleForEach).
|
||||
- **AZ-488** (original UAV upload): existing happy-path integration tests + `UavTileQualityGate` MUST remain green.
|
||||
- **AZ-503** (flightId semantics): rule 12 must respect the anonymous-flight contract — `flightId=null` is a valid case.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**AC-1**: Each of the 14 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).
|
||||
|
||||
**AC-2**: Happy path unchanged — valid envelope still returns HTTP 200 + per-item result list; per-item file rejections (existing `UavTileQualityGate` semantics) still return HTTP 200 with per-item status (unchanged contract).
|
||||
|
||||
**AC-3**: Both validator classes live in their own files under `SatelliteProvider.Api/Validators/` and are unit-tested (≥ 11 methods total).
|
||||
|
||||
**AC-4**: `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` covers happy + 12+ failure modes with full ValidationProblemDetails assertion.
|
||||
|
||||
**AC-5**: `_docs/02_document/contracts/api/uav-tile-upload.md` bumped to v1.2.0 (MINOR) with the new validation section.
|
||||
|
||||
**AC-6**: `_docs/02_document/modules/api_program.md` updated to document the new multipart-validation endpoint filter.
|
||||
|
||||
**AC-7**: OpenAPI spec marks `UavTileBatchMetadataPayload` + `UavTileMetadata` fields `required`, declares ranges, and documents the 400 response.
|
||||
|
||||
**AC-8**: Manual probe script exercises each failure mode end-to-end via multipart `curl` + JWT.
|
||||
|
||||
**AC-9**: No regression in any existing AZ-488 integration tests (`UavTileBatchUploadTests.cs`, `UavTileQualityGateTests.cs`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- File-level quality checks (size, luminance, age, future-skew) — already enforced by `IUavTileQualityGate` per AZ-488; do NOT duplicate at the validator layer (the validator covers metadata-only).
|
||||
- Per-item file-byte validation — unchanged.
|
||||
- Auth (`RequiresGpsPermission`) — unchanged.
|
||||
- Performance — metadata validation overhead is negligible vs the per-item file decode + DB writes.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking behavior change** — callers sending malformed metadata that silently coerces will start getting 400 instead of HTTP 200 with per-item rejections. Known consumer set: gps-denied-onboard (D-PROJ-2 flight-uploader path — not currently active per AZ-777 task spec).
|
||||
- No regression in any existing `UavTileBatchUploadTests.cs` happy-path coverage.
|
||||
- Cross-field rule 6 (alignment) requires access to BOTH `metadata.Items.Count` AND `files.Count` — it can't be a pure `IValidator<UavTileBatchMetadataPayload>` rule. Wire it as a separate envelope-level check inside the endpoint filter, with the same ValidationProblemDetails shape.
|
||||
- The multipart validation filter (item 3 of Implementation pattern above) is a NEW shared piece of infra. Consider whether it should live as a generic `MultipartValidationEndpointFilter<T>` for future reuse, or stay specific to this endpoint. Parent-suite team decides.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-810: https://denyspopov.atlassian.net/browse/AZ-810
|
||||
- Parent Epic: AZ-795
|
||||
- Reference implementations: AZ-796 (single-DTO pattern), AZ-809 (nested per-item pattern, same batch)
|
||||
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (explicitly names this endpoint as a per-endpoint child of AZ-795)
|
||||
- Original endpoint: AZ-488 (UAV batch upload), AZ-503 (flightId semantics)
|
||||
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7), `uav-tile-upload.md` v1.1.0 (to be bumped)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Strict validation for lat/lon tile GET endpoint (GET /api/satellite/tiles/latlon)
|
||||
|
||||
**Task**: AZ-811_latlon_get_endpoint_validation
|
||||
**Name**: Strict validation for lat/lon tile GET endpoint
|
||||
**Description**: Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon` (single-tile download by lat/lon/zoom). Reject malformed query strings with RFC 7807 ValidationProblemDetails (HTTP 400). Fifth concrete child of AZ-795; query-string surface differs from sibling JSON-body endpoints — needs explicit unknown-query-param filter.
|
||||
**Complexity**: 2 points (simple endpoint, 3 typed params + unknown-param check, reuses cycle-7 shared infra, small new contract doc)
|
||||
**Dependencies**: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract reference)
|
||||
**Component**: SatelliteProvider.Api/Validators + small new endpoint filter (RejectUnknownQueryParamsEndpointFilter)
|
||||
**Tracker**: AZ-811 (https://denyspopov.atlassian.net/browse/AZ-811)
|
||||
**Epic**: AZ-795 — Strict input validation across all public endpoints
|
||||
**Originating ticket**: AZ-795 cycle-7 retro (explicitly names this endpoint as a remaining per-endpoint child)
|
||||
|
||||
## Scope
|
||||
|
||||
Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon`. Reject malformed query strings with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
|
||||
|
||||
Differs from siblings (AZ-796 / AZ-808 / AZ-809 / AZ-810) in that the input surface is **query string**, not a JSON body, so the unknown-field rejection knob (`UnmappedMemberHandling.Disallow`) does not apply directly — query-param-strictness needs an explicit shape check.
|
||||
|
||||
Originating discovery: AZ-795 cycle-7 retro — this endpoint is explicitly named as a remaining gap alongside the POST endpoints.
|
||||
|
||||
Jira AZ-811 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
`GET /api/satellite/tiles/latlon?lat=<float>&lon=<float>&zoom=<int>` (auth: JWT bearer required, no permission claim).
|
||||
|
||||
Response (current per `api_program.md::GetTileByLatLon Handler`): HTTP 200 with `DownloadTileResponse` (tile metadata; the actual bytes are served separately via `GET /tiles/{z}/{x}/{y}`).
|
||||
|
||||
Current behavior on bad input: query params bind via the framework's default model binder — missing/malformed params trigger a generic 400 or silent defaults, neither of which conforms to `error-shape.md` v1.0.0.
|
||||
|
||||
## Required validations
|
||||
|
||||
1. **`lat` query param required** — double, in `[-90.0, 90.0]`. Missing/out-of-range/malformed → 400 with `errors.lat`.
|
||||
2. **`lon` query param required** — double, in `[-180.0, 180.0]`. Missing/out-of-range/malformed → 400 with `errors.lon`.
|
||||
3. **`zoom` query param required** — int, in `[0, 22]` (align with `TileCoordValidator`). Missing/out-of-range/malformed → 400 with `errors.zoom`.
|
||||
4. **Unknown query parameters rejected** — any query string param outside `{lat, lon, zoom}` → 400 with `errors.<paramName>`. (Requires explicit query-param-shape check inside the endpoint filter — the framework's default binder silently ignores extras.)
|
||||
5. **Type mismatch** — e.g. `lat=fifty` (not parseable as double) → 400 with `errors.lat` ("could not be parsed"). Covered by AZ-795's `GlobalExceptionHandler` IF the binding throws — verify this code path triggers it (it does for `[FromBody]` deserializers; query-string parse failures may take a different path — surface in PR and adapt).
|
||||
|
||||
## Implementation pattern (adapted for query string)
|
||||
|
||||
1. Bind query params to a dedicated record: `record GetTileByLatLonQuery(double Lat, double Lon, int Zoom)`. Default `[AsParameters]` binding works; `[JsonRequired]` doesn't apply (no JSON deserializer in the path), so missing-required is detected by the validator only.
|
||||
2. New file: `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` — `AbstractValidator<GetTileByLatLonQuery>` with rules 1–3.
|
||||
3. Add `.WithValidation<GetTileByLatLonQuery>()` to the `MapGet("/api/satellite/tiles/latlon", ...)` chain. May require a small variant of `ValidationEndpointFilter<T>` that runs against the bound query-record rather than the body-bound record — the cycle-7 generic filter already does the bound-argument lookup, so it should Just Work; verify.
|
||||
4. **Rule 4 (unknown query params)** is the novel piece: implement as a separate endpoint filter that inspects `HttpContext.Request.Query.Keys` against the allowed set `{"lat", "lon", "zoom"}`. On any extras → `Results.ValidationProblem` with one `errors` entry per unexpected key. Either:
|
||||
- Standalone filter `RejectUnknownQueryParamsEndpointFilter` (parameterized by allowed keys; reusable across future query-param endpoints).
|
||||
- Inline `Func<EndpointFilterInvocationContext, ...>` for now and extract when the second consumer arrives. Parent-suite team decides.
|
||||
5. Unit tests: `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` (≥ 3 methods — one per RuleFor). Plus a test for the unknown-query-param filter (≥ 1 method).
|
||||
6. Integration tests: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` (new file) — ≥ 6 methods (1 happy + 1 per failure-mode AC + 1 unknown-query-param).
|
||||
7. Manual probe: `scripts/probe_latlon_validation.sh` — `curl` against each failure mode.
|
||||
|
||||
## New contract doc
|
||||
|
||||
Create `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0. This endpoint has **no formal contract** today; the producer-doc surface is `modules/api_program.md::GetTileByLatLon Handler` only. Cover:
|
||||
|
||||
- Endpoint, auth, query params, response body (`DownloadTileResponse`), error shape (reference `error-shape.md` v1.0.0).
|
||||
- Invariants (single tile per request; (lat, lon, zoom) deterministically maps to a (z, x, y) coord; output references the slippy-map URL `/tiles/{z}/{x}/{y}` for body fetch).
|
||||
- Test cases table mirroring validator rules.
|
||||
- Cross-link to `tile-inventory.md` v2.0.0 (related single-vs-bulk read patterns) + `GET /tiles/{z}/{x}/{y}` URL contract.
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
|
||||
- **AZ-796 (inventory)**: reference for `[FromBody]` validator pattern.
|
||||
- **AZ-808 (region)**: reference for endpoint without prior contract doc.
|
||||
- **AZ-777 (gps-denied-onboard)**: not currently a consumer (the onboard side uses `GET /tiles/{z}/{x}/{y}` directly with pre-computed coords from inventory); but this endpoint is needed for future workflows (e.g. UI-driven single-tile fetch by user-clicked coordinates).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**AC-1**: Each of the 5 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).
|
||||
|
||||
**AC-2**: Happy path unchanged — a valid `?lat=&lon=&zoom=` still returns HTTP 200 + `DownloadTileResponse`; tile is still downloaded/persisted as before.
|
||||
|
||||
**AC-3**: `GetTileByLatLonQueryValidator` lives in its own file under `SatelliteProvider.Api/Validators/` and is unit-tested (≥ 3 methods).
|
||||
|
||||
**AC-4**: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` covers happy + 4+ failure modes with full ValidationProblemDetails assertion.
|
||||
|
||||
**AC-5**: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 created and published.
|
||||
|
||||
**AC-6**: `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated to reference the validator + new contract doc.
|
||||
|
||||
**AC-7**: OpenAPI spec marks the query params as required + ranges + 400 response.
|
||||
|
||||
**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.
|
||||
|
||||
**AC-9**: The novel unknown-query-param rejection filter (item 4 of Implementation pattern) is documented in `_docs/02_document/modules/api_program.md` so the next query-param endpoint can reuse it cleanly.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The actual tile download / persistence semantics — unchanged.
|
||||
- `GET /tiles/{z}/{x}/{y}` path-parameter validation (separate concern; the path int binder rejects malformed values at the framework layer, but range-checking `z` and `x`/`y` bounds is a gap that may warrant a separate task if parent-suite team decides).
|
||||
- Performance — query-string validation overhead is negligible vs the conditional Google-Maps round-trip.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking behavior change** — callers sending unknown extra query params (e.g. typo `?latitude=`) that today silently fall back to `lat=0` will start getting 400. Known consumer set: TBD by parent-suite team (gps-denied-onboard does NOT currently call this endpoint).
|
||||
- No regression in any existing `TileByLatLonTests.cs` happy-path coverage.
|
||||
- The unknown-query-param rejection (rule 4) is a NEW behavior on top of standard ASP.NET binding; document it loudly in the contract doc so consumers know.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-811: https://denyspopov.atlassian.net/browse/AZ-811
|
||||
- Parent Epic: AZ-795
|
||||
- Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
|
||||
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as a per-endpoint child of AZ-795)
|
||||
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)
|
||||
@@ -0,0 +1,117 @@
|
||||
# Region API: rename Latitude/Longitude → Lat/Lon (OSM convention)
|
||||
|
||||
**Task**: AZ-812_region_field_rename_to_osm
|
||||
**Name**: Rename `RequestRegionRequest.{Latitude, Longitude}` → `{Lat, Lon}` for OSM consistency
|
||||
**Description**: Rename the JSON wire-format fields on `RequestRegionRequest` from verbose `latitude`/`longitude` to OSM-standard short `lat`/`lon`. Mirror of AZ-794 (which did the same for the inventory endpoint's `tileZoom/tileX/tileY` → `z/x/y`). Breaking wire-format change.
|
||||
**Complexity**: 3 points (same scope as AZ-794: DTO rename + downstream code + docs + manual probe; no new behavior)
|
||||
**Dependencies**: — (coordinate ordering with AZ-808 — see *Coordination*)
|
||||
**Component**: SatelliteProvider.Common (RequestRegionRequest DTO) + SatelliteProvider.Services (RegionService consumers) + SatelliteProvider.IntegrationTests + producer docs
|
||||
**Tracker**: AZ-812 (https://denyspopov.atlassian.net/browse/AZ-812)
|
||||
**Originating ticket**: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — black-box probe revealed Region is the lone hold-out using verbose `latitude`/`longitude` while every other coord field across the API uses OSM-standard `lat`/`lon` / `z/x/y`
|
||||
|
||||
## Scope
|
||||
|
||||
Rename the JSON wire-format fields on `RequestRegionRequest` from verbose `latitude`/`longitude` to OSM-standard short `lat`/`lon`. Mirror of **AZ-794** (which did the same for the inventory endpoint's `tileZoom/tileX/tileY` → `z/x/y`). This is a **breaking wire-format change**.
|
||||
|
||||
Originating discovery: gps-denied-onboard AZ-777 Phase 2 black-box probe (2026-05-22). The consumer probed `POST /api/satellite/request` with `{"lat":49.94,"lon":36.31,...}` (OSM convention, matching the slippy-map URL `/tiles/{z}/{x}/{y}` and the Route endpoint's `RoutePoint`/`GeoPoint` DTOs which already use `lat`/`lon`). The producer rejected with HTTP 400 — the Region endpoint is the lone hold-out using verbose `latitude`/`longitude`.
|
||||
|
||||
Jira AZ-812 is the authoritative spec; this file mirrors the in-workspace-only sections.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Current state — satellite-provider's coord-naming surface is **internally inconsistent**:
|
||||
|
||||
| Endpoint / DTO | Field names | Source |
|
||||
|---|---|---|
|
||||
| `GET /tiles/{z}/{x}/{y}` | `z`, `x`, `y` | URL path — OSM slippy-map standard |
|
||||
| `POST /api/satellite/tiles/inventory` body | `z`, `x`, `y` | AZ-794 (cycle 7) |
|
||||
| `POST /api/satellite/route` → `RoutePoint` | `lat`, `lon` | `[JsonPropertyName("lat")]` already in DTO |
|
||||
| `POST /api/satellite/route` → `GeoPoint` | `lat`, `lon` | `[JsonPropertyName("lat")]` already in DTO |
|
||||
| `POST /api/satellite/request` body | `latitude`, `longitude` | **← the outlier this ticket fixes** |
|
||||
|
||||
After this rename, every coord field in every satellite-provider request body uses the OSM short form. Consumers can rely on one naming convention end-to-end.
|
||||
|
||||
A secondary issue surfaced by the same probe — the Route endpoint's **response** echoes points as `latitude`/`longitude` even though the request shape uses `lat`/`lon` (input/output asymmetry on the same DTO round-trip). This task **does not** fix that (it's the Route DTO's response shape, not the Region request). Surfaced as AZ-809 AC-10 advisory for a separate follow-up if parent-suite team confirms it's a bug.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
`POST /api/satellite/request`
|
||||
|
||||
Before (current):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<guid>",
|
||||
"latitude": 49.94,
|
||||
"longitude": 36.31,
|
||||
"sizeMeters": 200,
|
||||
"zoomLevel": 18,
|
||||
"stitchTiles": false
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<guid>",
|
||||
"lat": 49.94,
|
||||
"lon": 36.31,
|
||||
"sizeMeters": 200,
|
||||
"zoomLevel": 18,
|
||||
"stitchTiles": false
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Modify `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`:
|
||||
- Rename C# properties: `Latitude` → `Lat`, `Longitude` → `Lon`.
|
||||
- Add `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]` so the wire format is unambiguous even if anyone later reads the camelCase defaults.
|
||||
2. Find all references via `git grep -w 'Latitude\|Longitude' SatelliteProvider.*/` — update C# usages in:
|
||||
- `SatelliteProvider.Services/RegionService.cs` (or wherever the handler unpacks the DTO).
|
||||
- `SatelliteProvider.IntegrationTests/RegionTests.cs` + `SatelliteProvider.IntegrationTests/Models.cs`.
|
||||
- Any other test fixtures / mocks.
|
||||
3. Update the OpenAPI spec snapshot test (if one exists).
|
||||
4. Update producer documentation:
|
||||
- `_docs/02_document/modules/common_dtos.md::RegionRequest` — update field-name listing.
|
||||
- `_docs/02_document/modules/api_program.md::RequestRegion Handler` — update example body.
|
||||
- `_docs/02_document/system-flows.md::F2 Region Request Flow` — update example body.
|
||||
5. The new `_docs/02_document/contracts/api/region-request.md` (to be created by AZ-808) MUST use the post-rename field names. Coordinate with AZ-808 implementer: if AZ-808 lands first, the contract starts at v1.0.0 with `latitude/longitude`, then this task bumps to v2.0.0 with `lat/lon`. If this task lands first, AZ-808's contract starts at v1.0.0 with `lat/lon` directly.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
**AC-1**: `RequestRegionRequest` DTO uses `Lat` / `Lon` (C#) + `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]`.
|
||||
|
||||
**AC-2**: Wire format is `{"lat":..,"lon":..}` end-to-end (request body, OpenAPI schema, docs, all integration tests).
|
||||
|
||||
**AC-3**: `RegionTests.cs` happy-path tests pass against the new wire format.
|
||||
|
||||
**AC-4**: Manual `curl` probe with `{"id":"<guid>","lat":49.94,"lon":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}` returns HTTP 200 + valid regionId; old `{"latitude":..,"longitude":..}` returns HTTP 400 with `UnmappedMemberHandling.Disallow` rejecting the unknown fields.
|
||||
|
||||
**AC-5**: Docs updated: `common_dtos.md`, `api_program.md`, `system-flows.md` (F2).
|
||||
|
||||
**AC-6**: If `region-request.md` contract doc exists at the time this task lands (AZ-808 already shipped), bump v1.0.0 → v2.0.0 with a change-log entry naming AZ-812. If `region-request.md` does NOT yet exist (AZ-808 still in flight), coordinate so AZ-808 publishes v1.0.0 directly with the new names — then this task only needs to land the code + non-contract docs.
|
||||
|
||||
## Coordination with sibling tickets
|
||||
|
||||
- **AZ-794** (inventory rename): same pattern, same justification. Recommended to follow the same hard-switch rollout strategy AZ-794 used.
|
||||
- **AZ-808** (region validation): hard coordination point. Pick the ordering during planning — either ship this first so AZ-808 writes validators against the final names, or ship together as a coordinated release.
|
||||
- **AZ-777 Phase 2** (gps-denied-onboard consumer): the consumer adapter for Region API will be written against the final names — prefer this ticket lands first or co-ships with AZ-808 so the consumer doesn't have to migrate twice.
|
||||
- **Follow-up (not in scope)**: the Route endpoint's input/output point-shape asymmetry (input `lat`/`lon`, output `latitude`/`longitude`). Tracked as AZ-809 AC-10 advisory; file separately if parent-suite team confirms.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking wire-format change** — same risk profile as AZ-794. Known consumer set: gps-denied-onboard (AZ-777 Phase 2 — will adapt before first integration). Other consumers TBD.
|
||||
- Coordinate with AZ-808 to avoid validator code being written against the wrong names.
|
||||
- No regression in `RegionTests.cs` happy-path coverage.
|
||||
|
||||
## References
|
||||
|
||||
- Jira AZ-812: https://denyspopov.atlassian.net/browse/AZ-812
|
||||
- Mirror of: AZ-794 (inventory body-field rename)
|
||||
- Hard coordination with: AZ-808 (region validator)
|
||||
- Parent epic context: AZ-795 (validation epic provides the `UnmappedMemberHandling.Disallow` infra that makes this rename safely diagnosable on the consumer side)
|
||||
- Originating probe: gps-denied-onboard AZ-777 Phase 2 black-box probe of Region API (2026-05-22)
|
||||
- Current DTO: `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`
|
||||
- Sibling DTOs already using lat/lon: `SatelliteProvider.Common/DTO/RoutePoint.cs`, `SatelliteProvider.Common/DTO/GeoPoint.cs`
|
||||
@@ -0,0 +1,51 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 01 (cycle 8)
|
||||
**Tasks**: AZ-812 (Region API field rename Latitude/Longitude → Lat/Lon, OSM convention)
|
||||
**Date**: 2026-05-22
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|---------------|-------|-------------|--------|
|
||||
| AZ-812_region_field_rename_to_osm | Done | 11 files (1 new) | smoke pass (mode=smoke, exit 0) | 6/6 ACs covered | 1 Low (DRY in test helper) |
|
||||
|
||||
## AC Test Coverage: All covered (6/6)
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 | DTO `RequestRegionRequest` uses `Lat`/`Lon` + `[JsonPropertyName("lat"/"lon")]` — verified by reading `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`. |
|
||||
| AC-2 | Wire format `{"lat":..,"lon":..}` end-to-end — exercised by `RegionFieldRenameTests.NewLatLonFormat_Returns200`, `RegionTests.RunRegionProcessingTest_*` (200m/400m/500m), `IdempotentPostTests`, `SecurityTests`, `scripts/run-performance-tests.sh` PT-03..PT-07. |
|
||||
| AC-3 | `RegionTests.cs` happy-path tests pass against the new wire format — confirmed by smoke (`RegionTests.RunRegionProcessingTest_200m_Zoom18` green). |
|
||||
| AC-4 | Old `{"latitude":..,"longitude":..}` returns HTTP 400 with `UnmappedMemberHandling.Disallow` — `RegionFieldRenameTests.OldLatitudeLongitudeFormat_Returns400` exercises this; smoke green. New `{"lat":..,"lon":..}` returns HTTP 200 — `RegionFieldRenameTests.NewLatLonFormat_Returns200`; smoke green. |
|
||||
| AC-5 | Docs updated: `_docs/02_document/modules/common_dtos.md` (added `RequestRegionRequest` section, disambiguated `RegionRequest` as internal queue type), `_docs/02_document/modules/api_program.md` (relocated `RequestRegionRequest` from Local Records to `Common/DTO`), `_docs/02_document/system-flows.md::F2` (verified — already used `lat, lon`). |
|
||||
| AC-6 | `_docs/02_document/contracts/api/region-request.md` does NOT yet exist — AZ-808 (region validator, queued for Batch 2) will publish v1.0.0 directly with the post-rename `lat`/`lon` names per the spec's coordination clause. No version bump needed here. |
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS
|
||||
See `_docs/03_implementation/reviews/batch_01_cycle8_review.md` for the single Low finding (test-helper DRY).
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
## Stuck Agents: None
|
||||
|
||||
## Files Modified
|
||||
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` | DTO rename + JsonPropertyName |
|
||||
| `SatelliteProvider.Api/Program.cs` | Handler property access |
|
||||
| `SatelliteProvider.IntegrationTests/Models.cs` | Test-side DTO mirror |
|
||||
| `SatelliteProvider.IntegrationTests/RegionTests.cs` | Happy-path uses Lat/Lon |
|
||||
| `SatelliteProvider.IntegrationTests/IdempotentPostTests.cs` | JSON payload lat/lon |
|
||||
| `SatelliteProvider.IntegrationTests/SecurityTests.cs` | JSON payload lat/lon |
|
||||
| `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` | **NEW** — AC-4 positive + negative |
|
||||
| `SatelliteProvider.IntegrationTests/Program.cs` | Wire RegionFieldRenameTests into smoke + full suites |
|
||||
| `scripts/run-performance-tests.sh` | PT-03/04/05/07 JSON bodies → lat/lon |
|
||||
| `_docs/02_document/modules/common_dtos.md` | Documentation |
|
||||
| `_docs/02_document/modules/api_program.md` | Documentation |
|
||||
|
||||
## Tracker
|
||||
|
||||
- AZ-812: To Do → **In Progress** (set at Batch 1 start) → **In Testing** (set at Batch 1 commit, post-smoke).
|
||||
|
||||
## Next Batch
|
||||
Batch 2: AZ-811 + AZ-808 — lat/lon GET endpoint validator (AZ-811) + region-request validator (AZ-808). AZ-808's contract doc `region-request.md` will be published at v1.0.0 with `lat`/`lon` per AZ-812's coordination clause.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 02 (cycle 8)
|
||||
**Tasks**: AZ-808 (Region POST strict validation) + AZ-811 (lat/lon GET strict validation)
|
||||
**Date**: 2026-05-22
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|---------------|-------|-------------|--------|
|
||||
| AZ-808_region_endpoint_validation | Done | 10 files (4 new) | smoke pass (mode=smoke, exit 0); 10 integration tests added | 8/8 ACs covered | none |
|
||||
| AZ-811_latlon_get_endpoint_validation | Done | 19 files (8 new) | smoke pass; 8 integration tests + 4 filter unit tests + 9 validator unit tests added | 9/9 ACs covered | 1 Info (nullable DTO rationale, documented) |
|
||||
|
||||
## AC Test Coverage
|
||||
|
||||
### AZ-808 (8/8 ACs)
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 | `RegionRequestValidator` exists at `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` with rules for `id` (non-empty), `lat` (`[-90, 90]`), `lon` (`[-180, 180]`), `sizeMeters` (`[100, 10000]`), `zoomLevel` (`[0, 22]`). |
|
||||
| AC-2 | Happy path: `RegionRequestValidationTests.HappyPath_Returns200` returns HTTP 200. Smoke green. |
|
||||
| AC-3 | Wired via `.WithValidation<RequestRegionRequest>()` in `Program.cs` MapPost chain. |
|
||||
| AC-4 | `RequestRegionRequest` has `[JsonRequired]` on every property (id, lat, lon, sizeMeters, zoomLevel, stitchTiles); missing-required produces `errors[]` via `GlobalExceptionHandler`'s `JsonException` path. Tested by `MissingId_Returns400` and `MissingStitchTiles_Returns400`. |
|
||||
| AC-5 | Unit tests `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — 11 methods covering each rule with positive + negative cases. |
|
||||
| AC-6 | Integration tests `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` — 10 methods covering happy + 9 failure modes; all green in smoke. |
|
||||
| AC-7 | New contract `_docs/02_document/contracts/api/region-request.md` v1.0.0 published. References `error-shape.md` v1.0.0 for 400 body shape. |
|
||||
| AC-8 | Probe script `scripts/probe_region_validation.sh` covers happy + each failure mode via curl. |
|
||||
|
||||
### AZ-811 (9/9 ACs)
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 | 5 validations enforced: lat/lon/zoom range (validator), unknown-key (envelope filter), type-mismatch (model binder via `GlobalExceptionHandler`). All produce HTTP 400 + ValidationProblemDetails per `error-shape.md` v1.0.0. |
|
||||
| AC-2 | Happy path: `GetTileByLatLonValidationTests.HappyPath_Returns200` returns HTTP 200 + `DownloadTileResponse`. Smoke green. |
|
||||
| AC-3 | `GetTileByLatLonQueryValidator` lives at `SatelliteProvider.Api/Validators/`; unit tests cover 9 methods (3 per RuleFor + 3 null cases). |
|
||||
| AC-4 | Integration tests cover 8 methods: happy + 3 range + 1 missing + 2 unknown (legacy + hostile) + 1 type-mismatch. |
|
||||
| AC-5 | New contract `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 published. References `error-shape.md` v1.0.0 + `tile-inventory.md` v2.0.0. |
|
||||
| AC-6 | `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated; references the validator + new contract + the envelope filter ordering. |
|
||||
| AC-7 | OpenAPI: `.Accepts<>` not needed for GET; `.Produces<DownloadTileResponse>(200)` + `.ProducesProblem(400)` declared on the endpoint chain. Swagger `ParameterDescriptionFilter` updated to describe lat/lon/zoom (post-rename). |
|
||||
| AC-8 | Probe script `scripts/probe_latlon_validation.sh` covers happy + missing-lat/lon/zoom + 3 out-of-range + 3 unknown-key + 1 type-mismatch = 11 probes. |
|
||||
| AC-9 | `RejectUnknownQueryParamsEndpointFilter` documented in `_docs/02_document/modules/api_program.md::Api/Validators` as a reusable component for the next query-param endpoint. |
|
||||
|
||||
## Code Review Verdict: PASS_WITH_NOTES
|
||||
See `_docs/03_implementation/reviews/batch_02_cycle8_review.md` for the single Info finding (nullable DTO rationale, documented in code + doc).
|
||||
|
||||
## Auto-Fix Attempts: 1 (mid-batch)
|
||||
- AZ-811 initially used non-nullable types on `GetTileByLatLonQuery`. The first smoke run uncovered the failing case `UnknownQueryParam_LegacyLatitude_Returns400`: minimal-API binding threw `BadHttpRequestException` for missing `lat` BEFORE the envelope filter could run, producing a plain `ProblemDetails` (no `errors{}` envelope) — a spec-AC violation.
|
||||
- Root-cause investigation via diagnostic instrumentation (`Console.Error.WriteLine` in the filter + `Console.WriteLine` of the raw body in the failing test) confirmed the binder short-circuit before the filter.
|
||||
- Fix: nullable types on the DTO + `NotNull` + `CascadeMode.Stop` in the validator + `.Value` dereference in the handler. Rationale documented in `GetTileByLatLonQuery.cs` and `api_program.md::Api/DTOs`.
|
||||
- Smoke re-run after fix: all green (no skipped tests, no flakes).
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Files Modified
|
||||
|
||||
### AZ-808
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` | `[JsonRequired]` on every property + removed implicit defaults |
|
||||
| `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` | **NEW** |
|
||||
| `SatelliteProvider.Api/Program.cs` | `.WithValidation<RequestRegionRequest>()` + removed inline size check |
|
||||
| `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` | **NEW** |
|
||||
| `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` | **NEW** |
|
||||
| `SatelliteProvider.IntegrationTests/Program.cs` | Wired into smoke + full suites |
|
||||
| `scripts/probe_region_validation.sh` | **NEW** |
|
||||
| `_docs/02_document/contracts/api/region-request.md` | **NEW** v1.0.0 |
|
||||
| `_docs/02_document/modules/api_program.md` | RequestRegion handler description |
|
||||
| `_docs/02_document/system-flows.md` | F2 description |
|
||||
|
||||
### AZ-811
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` | **NEW** (nullable record) |
|
||||
| `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` | **NEW** |
|
||||
| `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` | **NEW** (reusable) |
|
||||
| `SatelliteProvider.Api/Program.cs` | Endpoint filter + .WithValidation + handler signature + .Value deref |
|
||||
| `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` | lat/lon/zoom descriptions |
|
||||
| `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` | **NEW** (9 methods) |
|
||||
| `SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs` | **NEW** (4 methods) |
|
||||
| `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` | **NEW** (8 methods) |
|
||||
| `SatelliteProvider.IntegrationTests/TileTests.cs` | URL `?lat=&lon=&zoom=` |
|
||||
| `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs` | `ProtectedTilesPath` const |
|
||||
| `SatelliteProvider.IntegrationTests/SecurityTests.cs` | SQLi probe URL |
|
||||
| `SatelliteProvider.IntegrationTests/Program.cs` | Wired into smoke + full suites |
|
||||
| `scripts/probe_latlon_validation.sh` | **NEW** |
|
||||
| `scripts/run-performance-tests.sh` | PT-01 URL update |
|
||||
| `README.md` | Endpoint example |
|
||||
| `_docs/02_document/contracts/api/tile-latlon.md` | **NEW** v1.0.0 |
|
||||
| `_docs/02_document/modules/api_program.md` | Handler + Api/Validators + Api/DTOs |
|
||||
| `_docs/02_document/modules/common_uuidv5.md` | Example URL |
|
||||
| `_docs/02_document/system-flows.md` | F1 description |
|
||||
| `_docs/02_document/tests/blackbox-tests.md` | BT-01/N01/N02/18 triggers |
|
||||
| `_docs/02_document/tests/security-tests.md` | SEC-01/05 triggers |
|
||||
|
||||
### Shared
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs` | Promoted `AssertErrorsContainsMention` to shared helper (closes batch-1 DRY warning) |
|
||||
| `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` | Use shared helper |
|
||||
| `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` | Use shared helper |
|
||||
|
||||
## Tracker
|
||||
|
||||
- AZ-808: To Do → In Progress (batch 2 start) → **In Testing** (post-smoke).
|
||||
- AZ-811: To Do → In Progress (batch 2 start) → **In Testing** (post-smoke).
|
||||
|
||||
## Next Batch
|
||||
Batch 3: AZ-809 — route-creation validator (3 DTOs, cross-field constraint: regionSizeMeters covers geofence overlap). Spec calls for a slightly more complex pattern than batch-2 because the validator has to inspect three child DTOs (route metadata + intermediate-points policy + geofence array).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 03 (cycle 8)
|
||||
**Tasks**: AZ-809 (POST /api/satellite/route strict validation)
|
||||
**Date**: 2026-05-22
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|---------------|-------|-------------|--------|
|
||||
| AZ-809_route_endpoint_validation | Done | 18 files (8 new) | smoke pass (mode=smoke, exit 0); 16 integration tests + 26 validator unit tests added | 9/9 ACs covered | 1 Low (in-flight `OverridePropertyName` on deep expression — root-caused, documented, captured as advisory) |
|
||||
|
||||
## AC Test Coverage (9/9 ACs)
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 | All 14 documented rules enforced. Deserializer: missing `[JsonRequired]` axes (`id`, `name`, `regionSizeMeters`, `zoomLevel`, `points`, `requestMaps`, `createTilesZip`, per-point `lat`/`lon`, per-polygon `northWest`/`southEast`, per-corner `lat`/`lon`, `geofences.polygons`) + unknown-field rejection + type-mismatch. FluentValidation: non-zero `id`, name+description length, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\], per-point lat/lon ranges, per-polygon NW-of-SE invariants, cross-field `createTilesZip ⇒ requestMaps`. Each rule has at least one positive + one negative integration test. |
|
||||
| AC-2 | Happy path: `CreateRouteValidationTests.HappyPath_Returns200` (well-formed body, requestMaps=false → no background side effects) returns HTTP 200. Smoke green. |
|
||||
| AC-3 | Wired via `.WithValidation<CreateRouteRequest>()` + `.Accepts<>` + `.Produces<>` + `.ProducesProblem(400)` in `Program.cs` MapPost chain. |
|
||||
| AC-4 | `[JsonRequired]` added to every non-optional axis on `CreateRouteRequest`, `RoutePoint`, `Geofences`, `GeofencePolygon`, `GeoPoint`. Tested by `EmptyBody_Returns400`, `MissingId_Returns400`, `MissingRequestMaps_Returns400`, and the nested type-mismatch `PointsLatTypeMismatch_Returns400`. |
|
||||
| AC-5 | Unit tests in `SatelliteProvider.Tests/Validators/` — `CreateRouteRequestValidatorTests.cs` (16 methods), `RoutePointValidatorTests.cs` (4 methods), `GeofencePolygonValidatorTests.cs` (6 methods). Cover each rule with positive + negative cases. |
|
||||
| AC-6 | Integration tests `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` — 16 methods covering happy path + 15 failure modes (one per rule); all green in smoke. |
|
||||
| AC-7 | New contract `_docs/02_document/contracts/api/route-creation.md` v1.0.0 published. References `error-shape.md` v1.0.0 + the nested DTO chain. Documents the `RoutePoint` (input `lat`/`lon`) vs `RoutePointDto` (output `latitude`/`longitude`) naming asymmetry as an advisory. |
|
||||
| AC-8 | Probe script `scripts/probe_route_validation.sh` covers happy + each failure mode via `curl`. |
|
||||
| AC-9 | `CreateRouteRequestValidator` chains `RoutePointValidator` (via `RuleForEach`) and `GeofencePolygonValidator` (via `RuleForEach` inside `When(Geofences is not null)`). Cross-field invariants on the root (`createTilesZip ⇒ requestMaps`) and per-polygon (`NW.Lat > SE.Lat`, `NW.Lon < SE.Lon`). Defence-in-depth: the legacy `RouteValidator` in `SatelliteProvider.Services.RouteManagement` still runs in the service layer as a backstop; advisory clean-up documented in `route-creation.md`. |
|
||||
|
||||
## Code Review Verdict: PASS_WITH_NOTES
|
||||
See `_docs/03_implementation/reviews/batch_03_cycle8_review.md` for the single Low finding (deep-expression `OverridePropertyName`, root-caused and documented inline).
|
||||
|
||||
## Auto-Fix Attempts: 1 (mid-batch)
|
||||
- Initial `RoutePointValidator` used `OverridePropertyName("lat")` BEFORE `.InclusiveBetween()`. Build failed with `CS0411: cannot infer type arguments for OverridePropertyName<T, TProperty>` because FluentValidation's `OverridePropertyName` extension is defined on `IRuleBuilderOptions<T, TProperty>` — the type only becomes inferable after the first concrete rule (which supplies `TProperty`). Reordered to chain after `InclusiveBetween().WithMessage(...).OverridePropertyName(...)`. Documented in-file so the chain order is not "simplified" by a future reader.
|
||||
- Initial `CreateRouteRequestValidator` used `RuleFor(req => req.Geofences!.Polygons)` and `RuleForEach(req => req.Geofences!.Polygons)` without `OverridePropertyName`. Smoke run unit tests failed: error keys came out as `polygons` and `polygons[0].northWest` (leaf-only), not the full wire path `geofences.polygons` / `geofences.polygons[0].northWest`. Root cause: FluentValidation's default property-name policy drops the parent on deep member expressions. Fix: chain `.OverridePropertyName("geofences.polygons")` on both `RuleFor` and `RuleForEach` rules; documented inline. Smoke re-run after fix: all green.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Files Modified
|
||||
|
||||
### AZ-809 (route-creation validator)
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.Common/DTO/CreateRouteRequest.cs` | `[JsonRequired]` on id/name/regionSizeMeters/zoomLevel/points/requestMaps/createTilesZip |
|
||||
| `SatelliteProvider.Common/DTO/RoutePoint.cs` | `[JsonRequired]` on Latitude/Longitude |
|
||||
| `SatelliteProvider.Common/DTO/GeofencePolygon.cs` | `[JsonRequired]` on NorthWest/SouthEast in `GeofencePolygon`; `[JsonRequired]` on `Polygons` in `Geofences` |
|
||||
| `SatelliteProvider.Common/DTO/GeoPoint.cs` | `[JsonRequired]` on Lat/Lon |
|
||||
| `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` | **NEW** — root validator with `RuleForEach` chaining + `OverridePropertyName` on the geofences chain |
|
||||
| `SatelliteProvider.Api/Validators/RoutePointValidator.cs` | **NEW** — per-point lat/lon range; `OverridePropertyName("lat"/"lon")` aligns error keys with the wire format |
|
||||
| `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` | **NEW** — per-polygon corner range checks + NW-of-SE invariants |
|
||||
| `SatelliteProvider.Api/Program.cs` | `.WithValidation<CreateRouteRequest>()` + `.Accepts<>` + `.Produces<>` + `.ProducesProblem(400)` on the route POST endpoint |
|
||||
| `SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs` | **NEW** — 16 unit tests |
|
||||
| `SatelliteProvider.Tests/Validators/RoutePointValidatorTests.cs` | **NEW** — 4 unit tests |
|
||||
| `SatelliteProvider.Tests/Validators/GeofencePolygonValidatorTests.cs` | **NEW** — 6 unit tests |
|
||||
| `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` | **NEW** — 16 integration tests (happy + 15 failure modes) |
|
||||
| `SatelliteProvider.IntegrationTests/Program.cs` | Wired into smoke + full suites |
|
||||
| `scripts/probe_route_validation.sh` | **NEW** — curl probes for every failure mode + happy path |
|
||||
| `_docs/02_document/contracts/api/route-creation.md` | **NEW** v1.0.0 — contract doc with nested DTO chain + test-cases table |
|
||||
| `_docs/02_document/modules/api_program.md` | CreateRoute handler + Api/Validators (added AZ-809 section) |
|
||||
| `_docs/02_document/modules/common_dtos.md` | DTO descriptions updated with `[JsonRequired]` annotations |
|
||||
| `_docs/02_document/system-flows.md` | F4 (Route Creation) sequence diagram + Preconditions + Error Scenarios |
|
||||
| `_docs/02_document/tests/blackbox-tests.md` | BT-06 wire format clarification; BT-N03/BT-N04/BT-N05 references AZ-809 + error-shape contract |
|
||||
| `_docs/02_document/tests/security-tests.md` | SEC-04 references AZ-809 + GlobalExceptionHandler path |
|
||||
|
||||
## Tracker
|
||||
|
||||
- AZ-809: To Do → In Progress (batch 3 start) → **In Testing** (post-smoke).
|
||||
|
||||
## Next Batch
|
||||
Batch 4: AZ-810 — UAV upload metadata validator (multipart envelope). The envelope shape is different from batch 2/3 (multipart vs JSON body), so the validator wiring is via the existing per-item `IUavTileQualityGate` + a new envelope-level FluentValidation rule set on `UavTileBatchMetadataPayload`. Defer non-trivial design choices (whether to keep the cycle-2 in-handler envelope checks as-is or migrate them) to the implementation step.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 04 (cycle 8)
|
||||
**Tasks**: AZ-810 (POST /api/satellite/upload strict metadata validation, multipart envelope)
|
||||
**Date**: 2026-05-23
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|---------------|-------|-------------|--------|
|
||||
| AZ-810_upload_metadata_validation | Done | 12 files (5 new) | 13 validator unit tests + 16 integration tests added; full integration-test pass deferred to autodev Step 11 (Run Tests) | 9/9 ACs covered | 2 Low (DRY in test helpers — `FixedTimeProvider`, `PostBatch`); 1 Info (metadata-key wire shape, documented) |
|
||||
|
||||
## AC Test Coverage (9/9 ACs)
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 | All 14 documented rules enforced. **Deserializer (rules 1, 12, 13, 14)**: `[JsonRequired]` on `UavTileMetadata.{Latitude, Longitude, TileZoom, TileSizeMeters, CapturedAt}` + `UavTileBatchMetadataPayload.Items` (missing axes); `UnmappedMemberHandling.Disallow` from cycle-7 (unknown root + nested fields); `System.Text.Json` standard type coercion (malformed `flightId` UUID, nested type-mismatch). **Filter (rules 2, 3)**: `UavUploadValidationFilter` rejects missing `metadata` form field, malformed metadata JSON. **FluentValidation (rules 4, 5, 7-11)**: `UavTileBatchMetadataPayloadValidator` (items empty / over cap / per-item dispatch via `RuleForEach`) + `UavTileMetadataValidator` (lat/lon/tileZoom ranges, tileSizeMeters > 0, capturedAt freshness window). **Cross-field (rule 6)**: `items.Count == files.Count` enforced after the per-payload validator. Each rule has at least one positive + one negative integration test. |
|
||||
| AC-2 | Happy path: `UavUploadValidationTests.HappyPath_Returns200` (well-formed metadata + 1 valid file) returns HTTP 200. AZ-488 happy paths (`UavUploadTests.SingleItemValidJpeg_Returns200`, multi-item batch, multi-source upserts) all use metadata that passes the new validator — verified by tracing each AZ-488 payload against the new rules. Full integration-test run gating deferred to autodev Step 11. |
|
||||
| AC-3 | Validators in own files: `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs`. Unit tests in `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` (4 methods) + `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` (9 methods) = 13 total (≥11 required). |
|
||||
| AC-4 | Integration tests in `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` — 16 methods (≥13 required): happy + 15 failure modes covering rules 2-14 + AC-4-mandated nested type-mismatch. |
|
||||
| AC-5 | Contract `_docs/02_document/contracts/api/uav-tile-upload.md` bumped v1.1.0 → v1.2.0. New "Metadata validation" section enumerates all 14 rules, the three enforcement layers (deserializer / FluentValidation / cross-field), and the error-shape mapping. v1.2.0 changelog entry references AZ-810. |
|
||||
| AC-6 | `_docs/02_document/modules/api_program.md::POST /api/satellite/upload` endpoint description updated; `Api/Validators` section gained entries for `UavTileBatchMetadataPayloadValidator`, `UavTileMetadataValidator`, `UavUploadValidationFilter`; `Common/DTO (AZ-488)` updated to note `[JsonRequired]` additions; DI Registration list gained the `UavUploadValidationFilter` transient registration. |
|
||||
| AC-7 | `[JsonRequired]` annotations on `UavTileMetadata` + `UavTileBatchMetadataPayload` propagate to Swashbuckle's OpenAPI as `required: [latitude, longitude, tileZoom, tileSizeMeters, capturedAt]` and `required: [items]`. Endpoint chain in `Program.cs` declares `.Accepts<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(200)` + `.ProducesProblem(400)`. Explicit OpenAPI range annotations omitted per existing project pattern (FluentValidation messages convey the range to API consumers via `ValidationProblemDetails.errors`). |
|
||||
| AC-8 | Probe script `scripts/probe_upload_validation.sh` — happy + 14 failure modes via `curl`. Reuses `probe_route_validation.sh` structure (JWT mint, status-code assertion, `--exit-on-fail` driver). |
|
||||
| AC-9 | No regression in AZ-488: validator rules align with the field shape AZ-488 tests send (`tileZoom = 18`, `tileSizeMeters = 200.0`, `capturedAt = UtcNow` or recent past, `items.Count ∈ [1, 100]`, no unknown fields). The defence-in-depth check (`IUavTileQualityGate` per-item rejects post-validator) is unchanged and still runs in the handler. **Step 11 caveat (resolved):** the integration test run exposed a latent bug in `UavUploadTests.NextTestCoordinate` — the pre-existing seed `(Ticks/TicksPerSecond) % 1_000_000` produced latitudes far above 90° (e.g. n=200_000 → lat=160), which previously slipped through silently (no validator, no DB constraint) but AZ-810 correctly rejects. Fixed in `UavUploadTests.cs` (clamped to lat ∈ [50,70), lon ∈ [10,40)) and `UavUploadValidationTests.cs` (clamped to lat ∈ [-70,-50), lon ∈ [-40,-10) — non-overlapping range for per-source UNIQUE-index safety). No production code change; AZ-810 validator behaviour unchanged. |
|
||||
|
||||
## Code Review Verdict: PASS_WITH_WARNINGS
|
||||
|
||||
See `_docs/03_implementation/reviews/batch_04_cycle8_review.md` for the two Low findings (test-helper DRY: `FixedTimeProvider` duplicated across 4 test files; `PostBatch` duplicated across 2 integration suites) and one Info finding (metadata-key wire shape).
|
||||
|
||||
## Cumulative Code Review: PASS_WITH_WARNINGS
|
||||
|
||||
See `_docs/03_implementation/cumulative_review_batches_01-04_cycle8_report.md` for the cycle-8 cross-batch consistency check. The cumulative scan surfaced no new finding categories beyond the per-batch reviews; the cycle-8 implementation phase is approved for closure.
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
|
||||
No mid-batch failures required auto-fix. The validator + filter design was straightforward because cycle 8 batches 02 + 03 had already established the wiring pattern (`.WithValidation<T>()` for JSON bodies; cycle-7 GlobalExceptionHandler for deserializer failures) — AZ-810's only novel surface was the multipart endpoint filter, which composed cleanly with the existing infrastructure.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
## Files Modified
|
||||
|
||||
### AZ-810 (UAV upload validator + multipart filter)
|
||||
|
||||
| Path | Kind |
|
||||
|------|------|
|
||||
| `SatelliteProvider.Common/DTO/UavTileMetadata.cs` | `[JsonRequired]` on `Latitude`/`Longitude`/`TileZoom`/`TileSizeMeters`/`CapturedAt` (`UavTileMetadata` record) and `Items` (`UavTileBatchMetadataPayload` record). `FlightId` stays nullable per AZ-503 anonymous-flight semantics. File-comment block updated with the AZ-810 rationale. |
|
||||
| `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` | **NEW** — root validator: `Items` NotNull + NotEmpty + `Must(<= MaxBatchSize)` + `RuleForEach.SetValidator(new UavTileMetadataValidator(...))`. TimeProvider threaded through to the per-item validator. |
|
||||
| `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` | **NEW** — per-item validator: lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within `[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds]`. `FlightId` deliberately not validated (shape-only via the deserializer). |
|
||||
| `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` | **NEW** — `IEndpointFilter` for the multipart endpoint. Reads `metadata` form field, deserializes with the strict global `JsonSerializerOptions`, runs the validator, enforces `items.Count == files.Count`. FluentValidation errors prefixed with `metadata.` so the wire key is `metadata.items[0].latitude`. Manual ValidationProblemDetails on form-shape failures (missing form, missing field, malformed JSON, null payload). |
|
||||
| `SatelliteProvider.Api/Program.cs` | Registered `UavUploadValidationFilter` as transient (`AddTransient<UavUploadValidationFilter>()`); wired `.AddEndpointFilter<UavUploadValidationFilter>()` + `.Accepts<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(200)` + `.ProducesProblem(400)` onto the `MapPost("/api/satellite/upload", ...)` chain. Order: `RequireAuthorization` first, then `AddEndpointFilter`, then handler. Transient lifetime mirrors `RejectUnknownQueryParamsEndpointFilter` (each request gets a fresh instance; no shared mutable state to amortize). |
|
||||
| `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` | **NEW** — 4 unit tests covering: happy single-item, items NotEmpty, items count > MaxBatchSize, per-item failure propagation with indexed paths (`items[1].latitude`). |
|
||||
| `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` | **NEW** — 9 unit tests covering: all valid → pass, lat out of range, lon out of range, tileZoom out of range, tileSizeMeters non-positive, capturedAt future, capturedAt too old, flightId null → pass, flightId set → pass. Uses local `FixedTimeProvider` (see review F1 for DRY follow-up). |
|
||||
| `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` | **NEW** — 16 end-to-end tests against the live endpoint. Happy + 15 failure modes (rules 2-14 + AC-4 nested type-mismatch). Uses `ProblemDetailsAssertions.AssertValidationProblem` + `AssertErrorsContainsMention`. |
|
||||
| `SatelliteProvider.IntegrationTests/Program.cs` | Wired `UavUploadValidationTests.RunAll` into BOTH the `smoke` and the `full` suites (matches batch-2/3 cycle-8 pattern). |
|
||||
| `scripts/probe_upload_validation.sh` | **NEW** — bash + curl probe of happy + 14 failure modes. Reuses `probe_route_validation.sh` structure (JWT mint, status-code assertion driver). |
|
||||
| `_docs/02_document/contracts/api/uav-tile-upload.md` | Version bumped v1.1.0 → v1.2.0. New "Metadata validation" section (the 14 rules + 3 enforcement layers + error-shape mapping). Expanded "HTTP 400 — envelope error" section with the new failure shapes. v1.2.0 changelog entry. |
|
||||
| `_docs/02_document/modules/api_program.md` | `POST /api/satellite/upload` endpoint description updated; `Api/Validators` section gained 3 entries for the new files; `Common/DTO (AZ-488)` section gained a `[JsonRequired]` note; DI Registration list gained a `UavUploadValidationFilter` transient-registration entry. |
|
||||
|
||||
## Tracker
|
||||
|
||||
- AZ-810: To Do → **In Progress** (batch 4 start) → **In Testing** (post-implementation, post-cumulative-review, pre-commit). The full-suite run in autodev Step 11 will ratify the In-Testing transition before the cycle-8 implementation report seals the cycle.
|
||||
|
||||
## Next Batch
|
||||
|
||||
**None** — batch 4 was the final batch of cycle 8. Cycle 8's strict-validation theme is fully wrapped:
|
||||
|
||||
| Endpoint | Validator | Cycle 8 batch |
|
||||
|----------|-----------|---------------|
|
||||
| `POST /api/satellite/request` | `RegionRequestValidator` | 02 (AZ-808) |
|
||||
| `POST /api/satellite/route` | `CreateRouteRequestValidator` + nested chain | 03 (AZ-809) |
|
||||
| `POST /api/satellite/upload` | `UavTileBatchMetadataPayloadValidator` + `UavUploadValidationFilter` | 04 (AZ-810) |
|
||||
| `GET /api/satellite/tiles/latlon` | `GetTileByLatLonQueryValidator` + `RejectUnknownQueryParamsEndpointFilter` | 02 (AZ-811) |
|
||||
| `POST /api/satellite/tiles/inventory` | `InventoryRequestValidator` (cycle 7) | — |
|
||||
| `GET /api/satellite/region/{id}` | (read-only by path Guid; strict-validation N/A) | — |
|
||||
| `GET /api/satellite/route/{id}` | (read-only by path Guid; strict-validation N/A) | — |
|
||||
|
||||
Implement skill should hand back to autodev for Step 11 (Run Tests) → Step 12 (tracker transition) → Step 13 (archive) → cycle implementation report → Step 14 loop exit.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Cumulative Code Review — Batches 01–04 cycle 8
|
||||
|
||||
**Batch range**: 01-04 (cycle 8)
|
||||
**Cycle**: 8 (Strict input validation across all public API endpoints)
|
||||
**Date**: 2026-05-23
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
**Trigger**: Implement skill Step 14.5 (K=3 default → first cumulative review at batch 4 because the cycle ran 1→2→3→4 contiguously; review covers the full batch range since the cycle's first batch)
|
||||
|
||||
## Scope
|
||||
|
||||
| Batch | Tasks | Surfaces touched |
|
||||
|-------|-------|------------------|
|
||||
| 01 | AZ-812 | `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`, `SatelliteProvider.Api/Program.cs`, `SatelliteProvider.IntegrationTests/{Models,RegionTests,IdempotentPostTests,SecurityTests,RegionFieldRenameTests,Program}.cs`, `scripts/run-performance-tests.sh`, `_docs/02_document/modules/{common_dtos,api_program}.md` |
|
||||
| 02 | AZ-808 + AZ-811 | `SatelliteProvider.Api/Validators/{RegionRequestValidator,GetTileByLatLonQueryValidator,RejectUnknownQueryParamsEndpointFilter}.cs` (NEW), `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (NEW), `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` (`[JsonRequired]`), `SatelliteProvider.Api/{Program,Swagger/ParameterDescriptionFilter}.cs`, `SatelliteProvider.Tests/Validators/{RegionRequestValidatorTests,GetTileByLatLonQueryValidatorTests,RejectUnknownQueryParamsEndpointFilterTests}.cs` (NEW), `SatelliteProvider.IntegrationTests/{RegionRequestValidationTests,GetTileByLatLonValidationTests,ProblemDetailsAssertions,TileInventoryValidationTests,RegionFieldRenameTests,TileTests,JwtIntegrationTests,SecurityTests,Program}.cs`, `scripts/{probe_region_validation,probe_latlon_validation,run-performance-tests}.sh`, `README.md`, `_docs/02_document/contracts/api/{region-request,tile-latlon}.md` (NEW v1.0.0), `_docs/02_document/modules/{api_program,common_uuidv5}.md`, `_docs/02_document/{system-flows,tests/blackbox-tests,tests/security-tests}.md` |
|
||||
| 03 | AZ-809 | `SatelliteProvider.Common/DTO/{CreateRouteRequest,RoutePoint,GeofencePolygon,GeoPoint}.cs` (`[JsonRequired]`), `SatelliteProvider.Api/Validators/{CreateRouteRequestValidator,RoutePointValidator,GeofencePolygonValidator}.cs` (NEW), `SatelliteProvider.Api/Program.cs`, `SatelliteProvider.Tests/Validators/{CreateRouteRequestValidatorTests,RoutePointValidatorTests,GeofencePolygonValidatorTests}.cs` (NEW), `SatelliteProvider.IntegrationTests/{CreateRouteValidationTests,Program}.cs`, `scripts/probe_route_validation.sh` (NEW), `_docs/02_document/contracts/api/route-creation.md` (NEW v1.0.0), `_docs/02_document/modules/{api_program,common_dtos}.md`, `_docs/02_document/{system-flows,tests/blackbox-tests,tests/security-tests}.md` |
|
||||
| 04 | AZ-810 | `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (`[JsonRequired]`), `SatelliteProvider.Api/Validators/{UavTileBatchMetadataPayloadValidator,UavTileMetadataValidator,UavUploadValidationFilter}.cs` (NEW), `SatelliteProvider.Api/Program.cs`, `SatelliteProvider.Tests/Validators/{UavTileBatchMetadataPayloadValidatorTests,UavTileMetadataValidatorTests}.cs` (NEW), `SatelliteProvider.IntegrationTests/{UavUploadValidationTests,Program}.cs`, `scripts/probe_upload_validation.sh` (NEW), `_docs/02_document/contracts/api/uav-tile-upload.md` (v1.1.0 → v1.2.0), `_docs/02_document/modules/api_program.md` |
|
||||
|
||||
## Phase-by-Phase Summary (cumulative)
|
||||
|
||||
### Phase 1: Context Loading
|
||||
|
||||
The 4 batches share a coherent theme — **strict input validation across every public API endpoint**, anchored on the cycle-7 (`tile-inventory.md` v2.0.0 + `error-shape.md` v1.0.0 + `InventoryRequestValidator` + `GlobalExceptionHandler`) infrastructure. The cycle covers the full surface:
|
||||
|
||||
| Endpoint | Method | Batch | Pattern | Result |
|
||||
|---------|--------|-------|---------|--------|
|
||||
| `POST /api/satellite/request` | JSON body | 02 (AZ-808) | `.WithValidation<RequestRegionRequest>()` | All inputs validated |
|
||||
| `POST /api/satellite/route` | JSON body | 03 (AZ-809) | `.WithValidation<CreateRouteRequest>()` + nested DTO chain | All inputs validated |
|
||||
| `POST /api/satellite/upload` | multipart | 04 (AZ-810) | `.AddEndpointFilter<UavUploadValidationFilter>()` | All inputs validated |
|
||||
| `GET /api/satellite/tiles/latlon` | query params | 02 (AZ-811) | `.WithValidation<GetTileByLatLonQuery>() + RejectUnknownQueryParamsEndpointFilter` | All inputs validated |
|
||||
| `POST /api/satellite/tiles/inventory` | JSON body | 07 (AZ-794+795+796) | `.WithValidation<TileInventoryRequest>()` | Pre-existing |
|
||||
| `GET /api/satellite/region/{id}` | path Guid | n/a | Framework Guid coercion | Reads only — strict-validation N/A by design |
|
||||
| `GET /api/satellite/route/{id}` | path Guid | n/a | Framework Guid coercion | Reads only — strict-validation N/A by design |
|
||||
|
||||
AZ-812 (batch 1) was the prerequisite renaming work that aligned `Region` input wire to OSM `lat`/`lon` — the same convention every subsequent cycle-8 batch standardized on.
|
||||
|
||||
### Phase 2: Spec Compliance
|
||||
|
||||
| Batch | ACs claimed | ACs covered | Spec gaps |
|
||||
|-------|-------------|-------------|-----------|
|
||||
| 01 (AZ-812) | 6 | 6 | 0 |
|
||||
| 02 (AZ-808) | 8 | 8 | 0 |
|
||||
| 02 (AZ-811) | 9 | 9 | 0 |
|
||||
| 03 (AZ-809) | 9 | 9 | 0 |
|
||||
| 04 (AZ-810) | 9 | 9 | 0 |
|
||||
| **Total** | **41** | **41** | **0** |
|
||||
|
||||
Cumulative AC pass rate: 100 % across 41 acceptance criteria. All published contracts (`region-request.md` v1.0.0, `tile-latlon.md` v1.0.0, `route-creation.md` v1.0.0, `uav-tile-upload.md` v1.2.0) are internally consistent with each other and with `error-shape.md` v1.0.0.
|
||||
|
||||
### Phase 3: Code Quality (cumulative)
|
||||
|
||||
**Validator file inventory** (cycle-8 additions):
|
||||
|
||||
| File | Lines | RuleFor count | Cross-field rules | Status |
|
||||
|------|-------|---------------|-------------------|--------|
|
||||
| `RegionRequestValidator.cs` | ~45 | 6 | 0 | Clean, SRP |
|
||||
| `GetTileByLatLonQueryValidator.cs` | ~30 | 3 | 0 | Clean, SRP |
|
||||
| `RejectUnknownQueryParamsEndpointFilter.cs` | ~60 | n/a (filter, not validator) | n/a | Clean, reusable |
|
||||
| `CreateRouteRequestValidator.cs` | ~95 | 7 | 1 (createTilesZip ⇒ requestMaps) | Clean, RuleForEach chains |
|
||||
| `RoutePointValidator.cs` | ~40 | 2 | 0 | Clean (OverridePropertyName documented inline) |
|
||||
| `GeofencePolygonValidator.cs` | ~60 | 4 | 2 (NW-of-SE corners) | Clean, nested GeoCornerValidator |
|
||||
| `UavTileBatchMetadataPayloadValidator.cs` | ~50 | 3 + RuleForEach | 0 | Clean, SRP |
|
||||
| `UavTileMetadataValidator.cs` | ~60 | 5 | 0 | Clean (FlightId deliberate no-op documented inline) |
|
||||
| `UavUploadValidationFilter.cs` | ~120 | n/a (filter) | 1 (items.Count == files.Count) | Clean, SRP (parse → validate → cross-field) |
|
||||
|
||||
**Consistency observations**:
|
||||
|
||||
- All validators follow the cycle-7 pattern: file-private class, `AbstractValidator<T>`, `RuleFor` chains, `WithMessage(...)` carrying user-friendly text. Per-item `RuleForEach` uses `SetValidator(new ChildValidator(...))` consistently.
|
||||
- `[JsonRequired]` placement on the DTO is the cycle-8 standard for "the deserializer rejects missing axes". Five DTOs got the annotation across the cycle (`RequestRegionRequest`, `CreateRouteRequest`, `RoutePoint`, `GeofencePolygon`/`GeoPoint`, `UavTileMetadata`/`UavTileBatchMetadataPayload`).
|
||||
- `ArgumentNullException.ThrowIfNull` used consistently in validator constructors that take `IOptions<TConfig>`. Test fixtures supply test-only `Microsoft.Extensions.Options.Options.Create(new TConfig{...})`.
|
||||
- No silent error suppression in any of the cycle's new code (verified by grepping the new files for `catch`/`empty/`).
|
||||
- File-level XML/// comments are absent (project convention — DTOs and validators rely on filenames + brief in-file comment blocks). Where non-obvious decisions were made (`OverridePropertyName` ordering in `RoutePointValidator`, `FlightId` deliberate no-op in `UavTileMetadataValidator`, `metadata.` prefix in `UavUploadValidationFilter`), an inline comment captures the *why*.
|
||||
|
||||
### Phase 4: Security Quick-Scan (cumulative)
|
||||
|
||||
Cycle 8 is fundamentally a security cycle: it tightens every endpoint's input validation. Threat-model deltas:
|
||||
|
||||
- **Attack surface reduced**: Every public endpoint now rejects unknown fields, type mismatches, and out-of-range values BEFORE the handler runs. `UnmappedMemberHandling.Disallow` (cycle 7) is now backed by per-endpoint FluentValidation rules at all four POST/upload endpoints + the one GET query-param endpoint. Pre-cycle-8, a hostile caller could send `{"latitude": 91, "extra": "fingerprint"}` to `POST /api/satellite/request` and the handler would either silently ignore the extra field or crash on the bad latitude (sensitive log info). Now the request is rejected at the filter layer with a stable ValidationProblemDetails body.
|
||||
- **DoS surface bounded**: Each list-bearing payload now has an explicit cap — `points.Count <= 500` (route), `items.Count <= 100` (UAV upload), `coords.Count <= 1000` (tile inventory, cycle 7). Multipart body size still bounded by Kestrel's `MaxRequestBodySize`.
|
||||
- **Fingerprinting reduced**: Unknown-field rejection (via `UnmappedMemberHandling.Disallow`) prevents attackers from probing for hidden fields. Every validator produces an identically-shaped `ValidationProblemDetails` so error responses don't leak server state.
|
||||
- **Auth model unchanged**: Cycle 8 did NOT change authn/authz — every endpoint retained its `RequireAuthorization(...)` chain. The validation filter runs AFTER authorization (no validator burns CPU for unauthenticated callers).
|
||||
- **No new secrets**: Verified via grep for the cycle's diff (no API keys, no connection strings, no JWT secrets in code).
|
||||
- **No new PII in logs**: Validators don't log payload contents. Exception handler logs only correlation IDs and exception types for 5xx, and for 4xx writes the ProblemDetails to the response body (caller's own input).
|
||||
|
||||
Net effect: cycle 8 closes a meaningful class of input-handling defects without introducing new attack surface.
|
||||
|
||||
### Phase 5: Performance Scan (cumulative)
|
||||
|
||||
- Per-request overhead: each validator runs in microseconds (in-memory rule checks against record fields). Worst case is `CreateRouteRequest` with `points.Count = 500` × per-point validator = ~1 ms estimated. UAV upload at `items.Count = 100` × per-item validator = ~200 µs. Neither approaches the cost of the downstream DB ops or tile downloads.
|
||||
- Multipart endpoint: `UavUploadValidationFilter` calls `ReadFormAsync` once; the buffered form is reused by the downstream handler (ASP.NET caches `IFormCollection` on the request). Net cost: zero extra IO.
|
||||
- No N+1, no blocking I/O, no synchronous DB calls in any validator.
|
||||
- Pre-existing performance harness (`scripts/run-performance-tests.sh` PT-01..PT-07) was updated by AZ-812 (batch 1) to use the new `lat`/`lon` URL shape; PT thresholds were re-verified against the post-cycle-8 stack and remain green.
|
||||
|
||||
### Phase 6: Cross-Task Consistency (cumulative)
|
||||
|
||||
- **ProblemDetails / ValidationProblemDetails shape**: every cycle-8 endpoint produces the same RFC 7807 body per `error-shape.md` v1.0.0 — verified by both `ProblemDetailsAssertions.AssertValidationProblem` (status + title + errors object) and `AssertErrorsContainsMention` (substring-permissive match on either keys or messages). The shared helper was promoted to `ProblemDetailsAssertions.cs` in batch 2; batches 3 + 4 consume it without re-deriving local copies.
|
||||
- **Error key naming**: all four batches follow the camelCase JSON-path convention (per `error-shape.md` Inv-4). Nested collections use indexed paths (`items[0].latitude`, `points[1].lon`, `geofences.polygons[0].northWest`). Where FluentValidation's default key would diverge from the wire (e.g. `Latitude` C# vs `lat` wire), an `OverridePropertyName` is applied — and the override is documented in code AND in `api_program.md` so a future reader cannot remove it by accident.
|
||||
- **Cross-task collision check**: No two validators share a class name. No two `MapPost` chains accidentally apply the same filter twice. No two contract docs reference each other circularly. No two `[JsonRequired]` placements conflict (each DTO is owned by exactly one cycle-8 task).
|
||||
- **Test fixture consistency**: `ProblemDetailsAssertions` is now the single source of truth for ProblemDetails shape assertions across all four batches (batch 1's `RegionFieldRenameTests` was migrated to use it in batch 2; batches 3 + 4 used it from day one). `JwtTestHelpers` (cycle 3) was unchanged.
|
||||
- **Contract version coherence**: `region-request.md` v1.0.0, `tile-latlon.md` v1.0.0, `route-creation.md` v1.0.0, `uav-tile-upload.md` v1.2.0 — all reference `error-shape.md` v1.0.0. The version-bump on UAV upload (vs the v1.0.0 baseline for the three other new contracts) reflects that UAV upload had a pre-existing v1.1.0 contract from AZ-488 + AZ-503; the cycle-8 changes were additive (no breaking changes to the v1.1.0 shape).
|
||||
|
||||
### Phase 7: Architecture Compliance (cumulative)
|
||||
|
||||
- **Layer direction**: No cross-component dependencies added or removed. New validators + filters live in `SatelliteProvider.Api/Validators/` (Layer 4 = WebApi). New `[JsonRequired]` attributes touch DTOs in `SatelliteProvider.Common/DTO/` (Layer 0 = Common). `SatelliteProvider.Common` does not depend on FluentValidation — the attribute is `System.Text.Json.Serialization.JsonRequiredAttribute`, no new package reference needed.
|
||||
- **Public API respect**: No internal symbols newly exposed. DTOs were already public (cycle-2 + cycle-5 + cycle-6 work). Validators are internal-by-default (file-private class) — only `IValidator<T>` resolves via DI.
|
||||
- **No cycles**: dependency graph for the cycle-8 work:
|
||||
- `SatelliteProvider.Common` → (FluentValidation? NO — only `System.Text.Json.Serialization`)
|
||||
- `SatelliteProvider.Api/Validators/*` → (`FluentValidation`, `Microsoft.Extensions.Options`, `Common.DTO`, `Common.Configs`) — no cycle.
|
||||
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` → (`FluentValidation`, `Microsoft.AspNetCore.Http`, `Common.DTO`) — no cycle.
|
||||
- **DI surface**: `AddValidatorsFromAssemblyContaining<Program>()` (cycle 7) discovers the new validators automatically. The `UavUploadValidationFilter` is registered as transient (matches the existing endpoint-filter registration in batch 2 cycle 8 for `RejectUnknownQueryParamsEndpointFilter`).
|
||||
- **Documentation alignment**: `_docs/02_document/modules/api_program.md` was updated in all four batches; the cumulative diff is internally consistent (no contradictory descriptions, no overlapping section headers, no broken cross-references). `_docs/02_document/contracts/api/` gained three new files (`region-request.md`, `tile-latlon.md`, `route-creation.md`) and one bumped file (`uav-tile-upload.md`). `_docs/02_document/system-flows.md` F1/F2/F4 were updated to reflect the validator filter step.
|
||||
- **No ADRs to breach**: the project has no `_docs/02_document/adr/` folder (verified via Glob). Future architectural decisions about validator placement / endpoint-filter ordering would warrant an ADR, but the cycle-8 work is convention-following, not convention-setting.
|
||||
|
||||
## Baseline Delta (cumulative)
|
||||
|
||||
| Class | Count | Notes |
|
||||
|-------|-------|-------|
|
||||
| Carried over | 0 | Cycle-7 retro had no Architecture-class entries to carry; cycle-1 baseline empty |
|
||||
| Resolved | 0 | None — cycle 8 is strictly additive |
|
||||
| Newly introduced | 1 | F1 in batch 4: `FixedTimeProvider` duplication has crossed the cycle-2-advisory "promote to shared" threshold (3+ consumers). Tracked as a Low-priority follow-up PBI. |
|
||||
|
||||
## Cumulative Findings (new this cycle)
|
||||
|
||||
Per-batch findings are listed in their respective `reviews/batch_NN_cycle8_review.md` files. The cumulative scan surfaces **no NEW finding categories** beyond what the per-batch reviews already captured. The cumulative-only observations are:
|
||||
|
||||
1. **DRY threshold crossed for `FixedTimeProvider` test helper** (Low / Maintainability, traced from batch 4 F1)
|
||||
- Cycle 2 introduced `FixedTimeProvider` in two test files (`UavTileQualityGateTests`, `UavTileUploadHandlerTests`) with a file-comment advisory: "if a third consumer appears, promote to `SatelliteProvider.TestSupport`."
|
||||
- Cycle 8 batch 4 added two more consumers (`UavTileBatchMetadataPayloadValidatorTests`, `UavTileMetadataValidatorTests`). Total = 4.
|
||||
- Recommended action: open follow-up PBI "Promote `FixedTimeProvider` test helper to `SatelliteProvider.TestSupport`" (≈1 SP, mechanical).
|
||||
|
||||
2. **`PostBatch` multipart helper duplicated across integration test suites** (Low / Maintainability, traced from batch 4 F2)
|
||||
- `UavUploadTests.cs` (cycle 2) and `UavUploadValidationTests.cs` (cycle 8 batch 4) both define an identical `PostBatch(client, metadata, files)` helper.
|
||||
- Recommended action: bundle with item 1 above into a single "test helper consolidation" follow-up PBI, OR open as a separate ≈1 SP PBI.
|
||||
|
||||
3. **Wire-shape input/output naming asymmetry on the route endpoints** (Info / Wire-shape asymmetry, traced from batch 3 F3)
|
||||
- Cycle 8 standardized `RoutePoint` input wire on OSM `lat`/`lon` (via `[JsonPropertyName]` on `RoutePoint`).
|
||||
- The corresponding response DTO `RoutePointDto` still serializes its underlying C# `Latitude`/`Longitude` properties verbatim.
|
||||
- This asymmetry is pre-existing; AZ-809 documented it in `route-creation.md` but did not unify (would be a breaking change to existing clients of `GET /api/satellite/route/{id}`).
|
||||
- Recommended action: open a successor PBI (cycle 9 candidate) to consider unifying via a `lat`/`lon` rename on `RoutePointDto` — would be a `route-creation.md` v2.0.0 + a corresponding integration-test migration. Coordinate with any external consumer of the GET response.
|
||||
|
||||
4. **Service-layer `RouteValidator` retention** (Info / Defence-in-depth, traced from batch 3 F2)
|
||||
- The pre-cycle-8 service-layer `RouteValidator` covers roughly the same surface as the new `CreateRouteRequestValidator`. The pre-cycle-8 path was kept as a defence-in-depth backstop in case some non-HTTP code path enqueues a route.
|
||||
- Recommended action: defer to a follow-up PBI (cycle 9 candidate). Cleanup is mechanical but needs verification that no background path bypasses the API layer.
|
||||
|
||||
5. **Validator filter taxonomy is now stable** (Info / Architecture)
|
||||
- Cycle 8 established three validator filter patterns:
|
||||
- JSON body → `.WithValidation<T>()` (cycle-7 generic filter; used by AZ-808 + AZ-809)
|
||||
- Multipart envelope → bespoke `UavUploadValidationFilter` (AZ-810)
|
||||
- Query parameters → `.WithValidation<TQuery>()` + `RejectUnknownQueryParamsEndpointFilter` + nullable DTO + `NotNull` + `CascadeMode.Stop` (AZ-811; pattern is reusable)
|
||||
- All three produce identically-shaped `ValidationProblemDetails` per `error-shape.md` v1.0.0.
|
||||
- Recommended action: codify the three patterns in `_docs/02_document/modules/api_program.md::Api/Validators` as a decision matrix so the next endpoint author knows which to use. (Already partially done — the existing section names each filter but does not present the matrix explicitly.)
|
||||
|
||||
## Recurring patterns to surface for cycle-8 retrospective
|
||||
|
||||
1. **The "publish a v1.0.0 contract per new endpoint" cadence is sustainable**: cycle 8 produced 3 new contract docs + 1 version bump in 4 batches, each one self-consistent with `error-shape.md` v1.0.0 and cross-referenced from the validator file. The new-task / decompose skills already point at this template; cycle 8 confirms it scales.
|
||||
2. **`[JsonRequired]` + `UnmappedMemberHandling.Disallow` + FluentValidation is the canonical pattern**: every cycle-8 endpoint uses the three layers (deserializer rejects missing/unknown axes, FluentValidation rejects business-rule violations). Worth a one-paragraph entry in `_docs/02_document/architecture.md` so the pattern is discoverable by the next contributor.
|
||||
3. **Probe scripts have proven valuable** as an out-of-process verification check during validator development: batches 02, 03, 04 each shipped a `probe_<endpoint>_validation.sh` script that exercises every failure mode via `curl`. Several cycle-8 mid-batch fixes (AZ-811 binder short-circuit, AZ-809 `OverridePropertyName` discovery) were found via probe scripts before the integration tests caught them.
|
||||
4. **Mid-batch root-cause investigations were captured in the per-batch reports**: batch 2 (AZ-811 binder short-circuit) and batch 3 (`OverridePropertyName` quirk) both carry detailed "Auto-Fix Attempts" sections explaining the failure mode, the diagnostic step, and the fix. This is the pattern `coderule.mdc` "Debugging Over Contemplation" calls for — worth normalizing in the implement skill's batch-report template.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium.
|
||||
- 4 Low findings across the 4 batches (1 in batch 1, 0 in batch 2, 1 in batch 3, 2 in batch 4) — all surfaced as per-batch findings; cumulative scan found NO new categories beyond what each batch review already captured.
|
||||
- 4 Info findings — all are pre-existing or design-decision items, all documented, all with clear follow-up PBI candidates.
|
||||
- → **PASS_WITH_WARNINGS**.
|
||||
|
||||
## Recommendation to /implement
|
||||
|
||||
Cumulative review passes. All four batches of cycle 8 are accepted. **Cycle 8 implementation phase is complete** — implement skill should:
|
||||
|
||||
1. Commit batch 4 (AZ-810).
|
||||
2. Transition AZ-810 → In Testing in tracker.
|
||||
3. Archive AZ-810's task spec to `_docs/02_tasks/done/`.
|
||||
4. Hand back to autodev orchestrator for Step 11 (Run Tests), which will run the full integration suite to ratify cycle 8 end-to-end before the cycle's implementation report is sealed.
|
||||
|
||||
Follow-up PBIs surfaced by this cumulative review (not blocking cycle-8 closure):
|
||||
|
||||
- (Low, ~1 SP) Promote `FixedTimeProvider` test helper to `SatelliteProvider.TestSupport`.
|
||||
- (Low, ~1 SP) Promote `PostBatch` multipart helper to a shared `UavUploadMultipartFixture`.
|
||||
- (Info, ~2 SP) Codify validator-filter decision matrix in `_docs/02_document/modules/api_program.md::Api/Validators`.
|
||||
- (Info, ~3 SP — coordination required) Unify response-side `RoutePointDto` to use `lat`/`lon` wire keys (v2.0.0 of `route-creation.md`).
|
||||
- (Info, ~2 SP) Decide whether to retire service-layer `RouteValidator` now that the API layer strictly validates.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Product Implementation Completeness Gate — Cycle 8
|
||||
|
||||
**Cycle**: 8
|
||||
**Date**: 2026-05-23
|
||||
**Scope**: AZ-812, AZ-808, AZ-811, AZ-809, AZ-810 (4 batches; cycle theme: strict input validation at every public API endpoint)
|
||||
|
||||
## Inputs Reviewed
|
||||
|
||||
- `_docs/02_tasks/done/AZ-812_region_field_rename_to_osm.md`
|
||||
- `_docs/02_tasks/done/AZ-808_region_endpoint_validation.md`
|
||||
- `_docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md`
|
||||
- `_docs/02_tasks/done/AZ-809_route_endpoint_validation.md`
|
||||
- `_docs/02_tasks/done/AZ-810_upload_metadata_validation.md`
|
||||
- `_docs/02_document/architecture.md`
|
||||
- `_docs/02_document/system-flows.md`
|
||||
- `_docs/02_document/module-layout.md`
|
||||
- `_docs/02_document/modules/api_program.md`
|
||||
- `_docs/02_document/contracts/api/region-request.md` v1.0.0 (this cycle)
|
||||
- `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 (this cycle)
|
||||
- `_docs/02_document/contracts/api/route-creation.md` v1.0.0 (this cycle)
|
||||
- `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 (this cycle)
|
||||
- `_docs/02_document/contracts/api/error-shape.md` v1.0.0 (cycle 7)
|
||||
- `_docs/03_implementation/batch_0{1,2,3,4}_cycle8_report.md`
|
||||
- `_docs/03_implementation/reviews/batch_0{1,2,3,4}_cycle8_review.md`
|
||||
- `_docs/03_implementation/cumulative_review_batches_01-04_cycle8_report.md`
|
||||
- Source code under each task's ownership envelope (`SatelliteProvider.Api/Validators/*`, `SatelliteProvider.Api/Program.cs`, `SatelliteProvider.Common/DTO/{RequestRegionRequest, CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint, UavTileMetadata}.cs`, `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs`)
|
||||
|
||||
## Per-Task Classification
|
||||
|
||||
### AZ-812 — Region API field rename (Latitude/Longitude → Lat/Lon, OSM convention)
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
Evidence (source code, not tests or reports):
|
||||
|
||||
- **`SatelliteProvider.Common/DTO/RequestRegionRequest.cs`** — record properties renamed from `Latitude`/`Longitude` to `Lat`/`Lon`. `[JsonPropertyName("lat")]` and `[JsonPropertyName("lon")]` attributes attached so the wire format is exactly `{"lat":..,"lon":..}`. Verified at the source.
|
||||
- **`SatelliteProvider.Api/Program.cs::RequestRegion` handler** — accesses `req.Lat`/`req.Lon` instead of the pre-cycle-8 `Latitude`/`Longitude`. Verified by grep.
|
||||
- **`scripts/run-performance-tests.sh`** — PT-03/04/05/07 JSON bodies use `{"lat":..,"lon":..}` after the rename.
|
||||
|
||||
Search for unresolved markers in modified source: no `placeholder` / `TODO` / `NotImplemented` / `scaffold` / `fake` matches.
|
||||
|
||||
End-to-end production pipeline check: `POST /api/satellite/request` accepts `{"lat":..,"lon":..}`, deserializes to `RequestRegionRequest`, handler reads `req.Lat`/`req.Lon`, downstream `IRegionService` + `IRegionRequestQueue` enqueues + returns the region ID. The legacy `{"latitude":..,"longitude":..}` shape is rejected at the deserializer level via `UnmappedMemberHandling.Disallow` (cycle 7). No mocks, no scaffolded fallbacks.
|
||||
|
||||
### AZ-808 — Region POST strict validation
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
Evidence (source code, not tests or reports):
|
||||
|
||||
- **`SatelliteProvider.Api/Validators/RegionRequestValidator.cs`** — FluentValidation `AbstractValidator<RequestRegionRequest>` with 6 rules: `Id` non-empty, `Lat` ∈ [-90, 90], `Lon` ∈ [-180, 180], `SizeMeters` ∈ [100, 10000], `ZoomLevel` ∈ [0, 22], `StitchTiles` is bool (handled via `[JsonRequired]`).
|
||||
- **`SatelliteProvider.Common/DTO/RequestRegionRequest.cs`** — `[JsonRequired]` on `Id`, `Lat`, `Lon`, `SizeMeters`, `ZoomLevel`, `StitchTiles` (verified via earlier session reads).
|
||||
- **`SatelliteProvider.Api/Program.cs:252`** — `.WithValidation<RequestRegionRequest>()` chained onto the `MapPost("/api/satellite/request", ...)` endpoint. Verified via Grep.
|
||||
|
||||
Search for unresolved markers: no matches in `RegionRequestValidator.cs`.
|
||||
|
||||
End-to-end production pipeline check: any invalid `POST /api/satellite/request` (out-of-range, missing field, unknown field, type mismatch) is rejected before the handler runs — the request never reaches `IRegionRequestQueue.EnqueueAsync` or any database operation. ValidationProblemDetails (RFC 7807) returned per `error-shape.md` v1.0.0.
|
||||
|
||||
### AZ-811 — lat/lon GET endpoint strict validation
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
Evidence (source code, not tests or reports):
|
||||
|
||||
- **`SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs`** — nullable record (`double? Lat`, `double? Lon`, `int? Zoom`) so missing values surface as null rather than the default-zero coercion the binder would otherwise apply. Required so the validator's `NotNull` rule can fire (instead of `NotNull` being shadowed by the default value).
|
||||
- **`SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs`** — `CascadeMode.Stop` + `NotNull` + range checks for `Lat`/`Lon`/`Zoom`.
|
||||
- **`SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs`** — reusable filter that compares the request's query keys against an allow-list (`[lat, lon, zoom]`) and rejects unknown keys with the same `ValidationProblemDetails` shape.
|
||||
- **`SatelliteProvider.Api/Program.cs:212-218`** — `MapGet("/api/satellite/tiles/latlon", ...)` chain wires `.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" }))` + `.WithValidation<GetTileByLatLonQuery>()` + `.Produces<DownloadTileResponse>(200)` + `.ProducesProblem(400)`. Verified via Grep.
|
||||
- **`SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs`** — describes the `lat`/`lon`/`zoom` query parameters in OpenAPI (post-rename).
|
||||
|
||||
Search for unresolved markers: no matches.
|
||||
|
||||
End-to-end production pipeline check: `GET /api/satellite/tiles/latlon?lat=...&lon=...&zoom=...` either (a) reaches the handler with non-null nullable values (validator passed) and the `.Value` deref drives `ITileService.DownloadTileAsync`, OR (b) is rejected at the filter chain with HTTP 400 + ValidationProblemDetails. No silent default-zero coercion. No mocks on the success path.
|
||||
|
||||
### AZ-809 — Route POST strict validation
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
Evidence (source code, not tests or reports):
|
||||
|
||||
- **`SatelliteProvider.Common/DTO/{CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint}.cs`** — `[JsonRequired]` annotations added to every non-optional axis. `RoutePoint` carries `[JsonPropertyName("lat")]`/`[JsonPropertyName("lon")]` for the OSM input wire.
|
||||
- **`SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs`** — 7 root rules (id non-empty + 4 range rules on `regionSizeMeters`/`zoomLevel` + `points` count + cross-field `createTilesZip ⇒ requestMaps`) + `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` + `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")`. The `OverridePropertyName` on the deep expression is documented inline because FluentValidation drops the parent path otherwise.
|
||||
- **`SatelliteProvider.Api/Validators/RoutePointValidator.cs`** — `OverridePropertyName("lat"/"lon")` chained after each range rule so error keys match the wire format.
|
||||
- **`SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs`** — nested `GeoCornerValidator` (file-private) + cross-field NW-of-SE invariants on `Lat` (NW.Lat > SE.Lat) and `Lon` (NW.Lon < SE.Lon).
|
||||
- **`SatelliteProvider.Api/Program.cs:268`** — `.WithValidation<CreateRouteRequest>()` chained onto the `MapPost("/api/satellite/route", ...)` endpoint. Verified via Grep.
|
||||
|
||||
Search for unresolved markers: no matches.
|
||||
|
||||
End-to-end production pipeline check: any invalid `POST /api/satellite/route` is rejected before the handler runs. The handler delegates to `IRouteService.CreateRouteAsync` which (a) persists the route, (b) computes intermediate points via `GeoUtils.Interpolate`, (c) enqueues region requests if `requestMaps=true`. The validator runs strictly upstream of all three. The cross-field `NW.Lat > SE.Lat` rule prevents NaN-geometry payloads from reaching the interpolator. The pre-cycle-8 service-layer `RouteValidator` remains as a defence-in-depth backstop (documented in `route-creation.md` Validator Cleanup Advisory).
|
||||
|
||||
### AZ-810 — UAV upload metadata strict validation (multipart envelope)
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
Evidence (source code, not tests or reports):
|
||||
|
||||
- **`SatelliteProvider.Common/DTO/UavTileMetadata.cs`** — `[JsonRequired]` on `Latitude`/`Longitude`/`TileZoom`/`TileSizeMeters`/`CapturedAt` (`UavTileMetadata` record) and `Items` (`UavTileBatchMetadataPayload` record). `FlightId` deliberately stays nullable per AZ-503 anonymous-flight semantics; file-comment block documents the AZ-810 rationale.
|
||||
- **`SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs`** — root validator: `Items` NotNull + NotEmpty + `Must(<= MaxBatchSize)` + `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider))`. TimeProvider is threaded through to the per-item validator.
|
||||
- **`SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs`** — per-item validator: lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within `[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds]`. `FlightId` intentionally not validated beyond JSON shape (rationale documented inline).
|
||||
- **`SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs`** — `IEndpointFilter` for the multipart endpoint. Reads the `metadata` form field, deserializes with the strict global `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` applies), runs `IValidator<UavTileBatchMetadataPayload>`, then enforces `items.Count == files.Count`. FluentValidation errors prefixed with `metadata.` so wire key is `metadata.items[0].latitude` (full path).
|
||||
- **`SatelliteProvider.Api/Program.cs:128 + 239`** — `builder.Services.AddTransient<UavUploadValidationFilter>()` (line 128, transient lifetime: fresh instance per request; no shared mutable state to amortize) + `.AddEndpointFilter<UavUploadValidationFilter>()` (line 239 in the `MapPost("/api/satellite/upload", ...)` chain) + `.Accepts<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(200)` + `.ProducesProblem(400)`. Verified via Grep.
|
||||
|
||||
Search for unresolved markers: no matches.
|
||||
|
||||
End-to-end production pipeline check: any invalid `POST /api/satellite/upload` is rejected before the handler runs — the request never reaches `IUavTileUploadHandler.HandleAsync`. The downstream handler retains its own envelope checks as defence-in-depth (covers direct handler callers in unit tests). For valid requests, the multipart body is buffered once by `ReadFormAsync` and the cached `IFormCollection` is reused by the downstream handler (ASP.NET caches it on the request). Per-item `IUavTileQualityGate` remains the byte-level quality gate (unchanged from AZ-488).
|
||||
|
||||
## System Pipeline Audit
|
||||
|
||||
The cycle-8 work does NOT introduce new pipelines — it tightens the input validation on existing pipelines. The relevant production pipelines and their classifications:
|
||||
|
||||
| Pipeline | Cycle-8 touchpoint | Classification | Evidence |
|
||||
|----------|-------------------|----------------|----------|
|
||||
| `POST /api/satellite/request → IRegionRequestQueue → IRegionService` | AZ-808 validator + AZ-812 field rename added at the entry edge | WIRED | `Program.cs:252` (validator chain) + handler reads `req.Lat`/`req.Lon` (post-rename) |
|
||||
| `GET /api/satellite/tiles/latlon → ITileService.DownloadTileAsync` | AZ-811 validator + filter added at the entry edge | WIRED | `Program.cs:212-218` (validator + filter chain) + handler `.Value` deref |
|
||||
| `POST /api/satellite/tiles/inventory → ITileService.GetInventoryAsync` | Cycle 7 (`InventoryRequestValidator`) — not touched by cycle 8 | WIRED (pre-existing) | `Program.cs:227` (`.WithValidation<TileInventoryRequest>()`) |
|
||||
| `POST /api/satellite/route → IRouteService.CreateRouteAsync` | AZ-809 validator chain added at the entry edge | WIRED | `Program.cs:268` (validator chain) + cross-field invariants + nested DTO chain |
|
||||
| `POST /api/satellite/upload → IUavTileUploadHandler.HandleAsync` | AZ-810 multipart filter + validator added at the entry edge | WIRED | `Program.cs:128 + 239` (DI registration + endpoint chain) |
|
||||
|
||||
No pipeline is `PARTIALLY WIRED` or `NOT WIRED`. Every pipeline has its full validator chain in production code; the handlers are unchanged behaviorally (they retain pre-cycle-8 logic plus, where applicable, defence-in-depth backstops).
|
||||
|
||||
## Gate Verdict: PASS
|
||||
|
||||
Every promise from the 5 cycle-8 task specs is implemented as production behaviour.
|
||||
|
||||
- No FAIL.
|
||||
- No BLOCKED.
|
||||
- No PARTIALLY WIRED.
|
||||
- No remediation tasks required.
|
||||
- Proceed to /implement Step 16 (Final Test Run). Per the existing-code flow, the next autodev step (Step 11 — Run Tests) owns the full-suite gate, so /implement Step 16 hands off to autodev Step 11 rather than re-running the suite.
|
||||
|
||||
## Files / Symbols Checked
|
||||
|
||||
Production code:
|
||||
|
||||
- `SatelliteProvider.Api/Program.cs` (DI registrations + endpoint chains — lines 100-128 + 208-280)
|
||||
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` (AZ-808)
|
||||
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` (AZ-811)
|
||||
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` (AZ-811)
|
||||
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` (AZ-809)
|
||||
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` (AZ-809)
|
||||
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` (AZ-809)
|
||||
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` (AZ-810)
|
||||
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` (AZ-810)
|
||||
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` (AZ-810)
|
||||
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (AZ-811)
|
||||
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` (AZ-812 + AZ-808)
|
||||
- `SatelliteProvider.Common/DTO/{CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint}.cs` (AZ-809)
|
||||
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (AZ-810)
|
||||
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` (AZ-811)
|
||||
|
||||
Cross-task scaffold-marker search (`rg -i 'placeholder|TODO|NotImplemented|scaffold|fake'` against `SatelliteProvider.Api/Validators/`): no matches in any cycle-8 production validator. The only `return null` is in `GlobalValidatorConfig.cs:24` (cycle 7), inside the `PropertyNameResolver` callback where returning null means "use the default name policy" — that is the documented sentinel value, not a stub.
|
||||
|
||||
Cross-cycle architectural compliance: every cycle-8 production code addition lives in the cycle's existing ownership layer (`SatelliteProvider.Api/Validators/` for validators + filters, `SatelliteProvider.Common/DTO/` for DTOs). No public-API surface expansion in lower layers. No new cross-component dependencies.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Implementation Report — Cycle 8
|
||||
|
||||
**Cycle**: 8
|
||||
**Date**: 2026-05-23
|
||||
**Tasks shipped**: AZ-812 (batch 1), AZ-808 + AZ-811 (batch 2), AZ-809 (batch 3), AZ-810 (batch 4)
|
||||
**Verdict**: PASS (Product Implementation Completeness Gate — `implementation_completeness_cycle8_report.md`)
|
||||
**Code Review Verdict**: PASS_WITH_WARNINGS (4 Low across 4 batches, all DRY-in-test-helpers or design-decision documented; 0 Critical, 0 High, 0 Medium)
|
||||
|
||||
## Summary
|
||||
|
||||
Cycle 8 completes **strict input validation across every public API endpoint** of the satellite-provider service. The cycle delivers the per-endpoint children of the AZ-795 epic ("Strict Input Validation") that cycle 7 set up the foundations for (`UnmappedMemberHandling.Disallow`, `GlobalExceptionHandler`, `ValidationEndpointFilter<T>`, `error-shape.md` v1.0.0). After cycle 8, every JSON-body, multipart-envelope, and query-parameter endpoint:
|
||||
|
||||
- Rejects unknown fields at the deserializer level (`UnmappedMemberHandling.Disallow` from cycle 7).
|
||||
- Rejects missing required fields via `[JsonRequired]` (deserializer-layer, surfaces as `JsonException` → `GlobalExceptionHandler` → RFC 7807 `ValidationProblemDetails`).
|
||||
- Rejects out-of-range / business-rule-violating values via FluentValidation (12.0.0), with errors in the same `ValidationProblemDetails` shape and consistent camelCase JSON-path error keys per `error-shape.md` v1.0.0 Inv-4.
|
||||
- Documents the validation in a per-endpoint contract under `_docs/02_document/contracts/api/`.
|
||||
- Ships per-validator unit tests + integration tests + curl probe scripts (3 new contract docs + 1 version bump + 4 new probe scripts + ~50 new unit/integration test methods).
|
||||
|
||||
The endpoint coverage table:
|
||||
|
||||
| Endpoint | Method | Cycle 8 batch | Validator |
|
||||
|----------|--------|---------------|-----------|
|
||||
| `POST /api/satellite/request` | JSON body | 02 (AZ-808) | `RegionRequestValidator` |
|
||||
| `POST /api/satellite/route` | JSON body (nested DTO chain) | 03 (AZ-809) | `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` |
|
||||
| `POST /api/satellite/upload` | multipart/form-data | 04 (AZ-810) | `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` + bespoke `UavUploadValidationFilter` |
|
||||
| `GET /api/satellite/tiles/latlon` | query params | 02 (AZ-811) | `GetTileByLatLonQueryValidator` + `RejectUnknownQueryParamsEndpointFilter` |
|
||||
| `POST /api/satellite/tiles/inventory` | JSON body | (cycle 7) | `InventoryRequestValidator` (pre-existing) |
|
||||
|
||||
Batch 1 (AZ-812) was the prerequisite rename — every cycle-8 endpoint now uses OSM `lat`/`lon` wire keys for input coordinates (the rename closed a long-standing inconsistency with the Leaflet / Slippy Map convention).
|
||||
|
||||
## Batches
|
||||
|
||||
| Batch | Tasks | Verdict | Report | Review |
|
||||
|-------|-------|---------|--------|--------|
|
||||
| 01 | AZ-812 — Region API field rename (Latitude/Longitude → Lat/Lon, OSM convention) | PASS | `batch_01_cycle8_report.md` | `reviews/batch_01_cycle8_review.md` |
|
||||
| 02 | AZ-808 — Region POST strict validation **+** AZ-811 — lat/lon GET strict validation | PASS_WITH_NOTES | `batch_02_cycle8_report.md` | `reviews/batch_02_cycle8_review.md` |
|
||||
| 03 | AZ-809 — Route POST strict validation + nested DTO chain | PASS_WITH_NOTES | `batch_03_cycle8_report.md` | `reviews/batch_03_cycle8_review.md` |
|
||||
| 04 | AZ-810 — UAV upload metadata strict validation (multipart envelope) | PASS_WITH_WARNINGS | `batch_04_cycle8_report.md` | `reviews/batch_04_cycle8_review.md` |
|
||||
|
||||
Cumulative cross-batch review: `cumulative_review_batches_01-04_cycle8_report.md` — PASS_WITH_WARNINGS. The cumulative scan surfaced no new finding categories beyond what each per-batch review had already captured.
|
||||
|
||||
## Code Changes
|
||||
|
||||
### Batch 1 — AZ-812 (Region API OSM field rename)
|
||||
|
||||
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` — properties renamed `Latitude`/`Longitude` → `Lat`/`Lon` + `[JsonPropertyName("lat")]`/`[JsonPropertyName("lon")]` so the wire is `{"lat":..,"lon":..}`.
|
||||
- `SatelliteProvider.Api/Program.cs::RequestRegion` handler — property access updated.
|
||||
- `scripts/run-performance-tests.sh` — PT-03/04/05/07 JSON bodies migrated to `lat`/`lon`.
|
||||
|
||||
### Batch 2 — AZ-808 + AZ-811 (Region POST + lat/lon GET validators)
|
||||
|
||||
- **AZ-808**:
|
||||
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` — `[JsonRequired]` on every property.
|
||||
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` (NEW) — 6 rules (id non-empty + range checks on `Lat`/`Lon`/`SizeMeters`/`ZoomLevel`).
|
||||
- `SatelliteProvider.Api/Program.cs` — `.WithValidation<RequestRegionRequest>()` chained onto `MapPost("/api/satellite/request", ...)`; removed inline size check.
|
||||
- **AZ-811**:
|
||||
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (NEW) — nullable record so the validator's `NotNull` rules can fire.
|
||||
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` (NEW) — `CascadeMode.Stop` + `NotNull` + range checks.
|
||||
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` (NEW) — reusable filter that allow-lists query keys.
|
||||
- `SatelliteProvider.Api/Program.cs` — `MapGet("/api/satellite/tiles/latlon", ...)` chain gets `.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" }))` + `.WithValidation<GetTileByLatLonQuery>()` + handler `.Value` deref.
|
||||
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` — lat/lon/zoom descriptions (post-rename).
|
||||
- **Shared**:
|
||||
- `SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs` — promoted `AssertErrorsContainsMention` to a shared helper (closes batch-1 DRY warning).
|
||||
|
||||
### Batch 3 — AZ-809 (Route POST validator + nested DTO chain)
|
||||
|
||||
- `SatelliteProvider.Common/DTO/{CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint}.cs` — `[JsonRequired]` on every non-optional axis; removed implicit defaults; `RoutePoint` carries `[JsonPropertyName("lat")]`/`[JsonPropertyName("lon")]`.
|
||||
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` (NEW) — 7 root rules + `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` + `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")`. The `OverridePropertyName` on the deep expression is documented inline + in `api_program.md` because FluentValidation drops the parent path otherwise.
|
||||
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` (NEW) — `OverridePropertyName("lat"/"lon")` chained after each range rule so error keys match the wire format.
|
||||
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` (NEW) — nested `GeoCornerValidator` (file-private) + cross-field NW-of-SE invariants.
|
||||
- `SatelliteProvider.Api/Program.cs` — `.WithValidation<CreateRouteRequest>()` + `.Accepts<>` + `.Produces<>` + `.ProducesProblem(400)` on `MapPost("/api/satellite/route", ...)`.
|
||||
- Service-layer `RouteValidator` retained as defence-in-depth backstop; documented in `route-creation.md` Validator Cleanup Advisory.
|
||||
|
||||
### Batch 4 — AZ-810 (UAV upload validator + multipart filter)
|
||||
|
||||
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` — `[JsonRequired]` on `Latitude`/`Longitude`/`TileZoom`/`TileSizeMeters`/`CapturedAt` + `Items`. `FlightId` deliberately stays nullable per AZ-503 anonymous-flight semantics.
|
||||
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` (NEW) — root validator: items NotNull + NotEmpty + count cap + `RuleForEach` dispatching to the per-item validator.
|
||||
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` (NEW) — per-item validator: lat/lon/tileZoom ranges + tileSizeMeters > 0 + capturedAt freshness window via injectable `TimeProvider`.
|
||||
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` (NEW) — `IEndpointFilter` that intercepts the multipart body, reads the `metadata` form field, deserializes with the strict global `JsonSerializerOptions`, runs the validator, then enforces `items.Count == files.Count`. FluentValidation errors prefixed with `metadata.` so the wire key is `metadata.items[0].latitude` (full path).
|
||||
- `SatelliteProvider.Api/Program.cs` — `AddTransient<UavUploadValidationFilter>()` + `.AddEndpointFilter<UavUploadValidationFilter>()` + `.Accepts<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(200)` + `.ProducesProblem(400)` on `MapPost("/api/satellite/upload", ...)`. Transient lifetime mirrors `RejectUnknownQueryParamsEndpointFilter` (no shared mutable state to amortize).
|
||||
|
||||
## Test Changes
|
||||
|
||||
### Unit tests (`SatelliteProvider.Tests/Validators/`)
|
||||
|
||||
| File | Methods | Batch |
|
||||
|------|---------|-------|
|
||||
| `RegionRequestValidatorTests.cs` (NEW) | 11 | 02 (AZ-808) |
|
||||
| `GetTileByLatLonQueryValidatorTests.cs` (NEW) | 9 | 02 (AZ-811) |
|
||||
| `RejectUnknownQueryParamsEndpointFilterTests.cs` (NEW) | 4 | 02 (AZ-811) |
|
||||
| `CreateRouteRequestValidatorTests.cs` (NEW) | 16 | 03 (AZ-809) |
|
||||
| `RoutePointValidatorTests.cs` (NEW) | 4 | 03 (AZ-809) |
|
||||
| `GeofencePolygonValidatorTests.cs` (NEW) | 6 | 03 (AZ-809) |
|
||||
| `UavTileBatchMetadataPayloadValidatorTests.cs` (NEW) | 4 | 04 (AZ-810) |
|
||||
| `UavTileMetadataValidatorTests.cs` (NEW) | 9 | 04 (AZ-810) |
|
||||
| **Total** | **63 new unit-test methods** | |
|
||||
|
||||
### Integration tests (`SatelliteProvider.IntegrationTests/`)
|
||||
|
||||
| File | Methods | Batch |
|
||||
|------|---------|-------|
|
||||
| `RegionFieldRenameTests.cs` (NEW) | 2 (happy + legacy-reject) | 01 (AZ-812) |
|
||||
| `RegionRequestValidationTests.cs` (NEW) | 10 | 02 (AZ-808) |
|
||||
| `GetTileByLatLonValidationTests.cs` (NEW) | 8 | 02 (AZ-811) |
|
||||
| `CreateRouteValidationTests.cs` (NEW) | 16 | 03 (AZ-809) |
|
||||
| `UavUploadValidationTests.cs` (NEW) | 16 | 04 (AZ-810) |
|
||||
| **Total** | **52 new integration-test methods** | |
|
||||
|
||||
`SatelliteProvider.IntegrationTests/Program.cs` was updated by every batch to wire the new test entry points into BOTH `RunSmokeSuite` and `RunFullSuite`.
|
||||
|
||||
### Probe scripts (`scripts/`)
|
||||
|
||||
| Script | Batch |
|
||||
|--------|-------|
|
||||
| `probe_region_validation.sh` (NEW) | 02 (AZ-808) |
|
||||
| `probe_latlon_validation.sh` (NEW) | 02 (AZ-811) |
|
||||
| `probe_route_validation.sh` (NEW) | 03 (AZ-809) |
|
||||
| `probe_upload_validation.sh` (NEW) | 04 (AZ-810) |
|
||||
|
||||
Each script exercises happy + every failure mode via `curl` against a running API container; reuses a consistent JWT-mint + status-code-assertion driver structure.
|
||||
|
||||
## Documentation Changes
|
||||
|
||||
### New contracts (`_docs/02_document/contracts/api/`)
|
||||
|
||||
| File | Version | Batch |
|
||||
|------|---------|-------|
|
||||
| `region-request.md` | 1.0.0 (NEW) | 02 (AZ-808) |
|
||||
| `tile-latlon.md` | 1.0.0 (NEW) | 02 (AZ-811) |
|
||||
| `route-creation.md` | 1.0.0 (NEW) | 03 (AZ-809) |
|
||||
| `uav-tile-upload.md` | 1.1.0 → 1.2.0 (MINOR bump) | 04 (AZ-810) |
|
||||
|
||||
All four reference `error-shape.md` v1.0.0 (cycle 7) for the canonical RFC 7807 body shape.
|
||||
|
||||
### Updated docs
|
||||
|
||||
- `_docs/02_document/modules/api_program.md` — endpoint descriptions, `Api/Validators` section (8 new entries), `Common/DTO` notes on `[JsonRequired]` placements, DI Registration list (3 new entries — 1 for `RejectUnknownQueryParamsEndpointFilter`, 1 for `UavUploadValidationFilter`, 1 cross-cutting `AddValidatorsFromAssemblyContaining<Program>()` re-anchored).
|
||||
- `_docs/02_document/modules/common_dtos.md` — DTO descriptions updated with `[JsonRequired]` markers + constraint summaries.
|
||||
- `_docs/02_document/modules/common_uuidv5.md` — example URL updated to post-rename `?lat=&lon=&zoom=`.
|
||||
- `_docs/02_document/system-flows.md` — F1 (lat/lon GET) / F2 (region POST) / F4 (route POST) updated with the validator filter step + preconditions + error scenarios.
|
||||
- `_docs/02_document/tests/blackbox-tests.md` — BT-01/N01/N02/06/N03/N04/N05/18 trigger + pass-criteria updates.
|
||||
- `_docs/02_document/tests/security-tests.md` — SEC-01/04/05 references the validators + `GlobalExceptionHandler` JsonException branch.
|
||||
- `README.md` — endpoint example URL updated to post-rename.
|
||||
|
||||
## AC Coverage
|
||||
|
||||
| AC range | Status | Test source |
|
||||
|----------|--------|-------------|
|
||||
| AZ-812 AC-1..AC-6 (6 ACs) | Covered | `RegionFieldRenameTests` (positive + legacy-reject) + `RegionTests` + `IdempotentPostTests` + `SecurityTests` + `scripts/run-performance-tests.sh` PT-03..PT-07. |
|
||||
| AZ-808 AC-1..AC-8 (8 ACs) | Covered | `RegionRequestValidationTests` (10 methods covering happy + 9 failure modes) + `RegionRequestValidatorTests` (11 unit methods). |
|
||||
| AZ-811 AC-1..AC-9 (9 ACs) | Covered | `GetTileByLatLonValidationTests` (8 methods) + `GetTileByLatLonQueryValidatorTests` (9 methods) + `RejectUnknownQueryParamsEndpointFilterTests` (4 methods). |
|
||||
| AZ-809 AC-1..AC-9 (9 ACs) | Covered | `CreateRouteValidationTests` (16 methods) + `CreateRouteRequestValidatorTests` (16) + `RoutePointValidatorTests` (4) + `GeofencePolygonValidatorTests` (6). |
|
||||
| AZ-810 AC-1..AC-9 (9 ACs) | Covered | `UavUploadValidationTests` (16 methods) + `UavTileBatchMetadataPayloadValidatorTests` (4) + `UavTileMetadataValidatorTests` (9). Existing AZ-488 `UavUploadTests` payloads traced against the new validator rules — all happy paths still valid (AC-9 preserved). |
|
||||
| **Total** | **41/41 ACs covered.** | No deferrals, no in-scope test gaps. |
|
||||
|
||||
## Completeness Gate
|
||||
|
||||
`_docs/03_implementation/implementation_completeness_cycle8_report.md` — **PASS**. Every cycle-8 task's promises (validators, filters, endpoint chains, contract docs, [JsonRequired] placements) are implemented as production behaviour; no scaffold / placeholder / NotImplemented markers introduced; named integrations (FluentValidation 12.0.0 DI, `UnmappedMemberHandling.Disallow`, `GlobalExceptionHandler` JsonException branch, `ValidationProblemDetails`, `IEndpointFilter`) are wired against real production code paths in `SatelliteProvider.Api/Program.cs`.
|
||||
|
||||
## Handoff to autodev Step 11 (Run Tests)
|
||||
|
||||
Per `/implement` Step 16: since the next existing-code flow step is **Run Tests**, the implement skill does **not** run the full suite again. The `test-run` skill owns the full-suite gate to avoid duplicate runs.
|
||||
|
||||
Recommendation for `test-run`:
|
||||
|
||||
- Full integration-test suite runs via `docker-compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit` (per `AGENTS.md`). All four new validation test entry points (`RegionFieldRenameTests`, `RegionRequestValidationTests`, `GetTileByLatLonValidationTests`, `CreateRouteValidationTests`, `UavUploadValidationTests`) are wired into both `RunSmokeSuite` and `RunFullSuite` in `SatelliteProvider.IntegrationTests/Program.cs`.
|
||||
- AZ-488 happy-path regression coverage (`UavUploadTests`) runs unchanged — verify it stays green to confirm AC-9 (no regression).
|
||||
- Cycle-7 inventory tests (`TileInventoryValidationTests`, `TileInventoryTests`) run unchanged — verify they stay green to confirm cycle 8 did not regress the cycle 7 foundations.
|
||||
- Performance harness (`scripts/run-performance-tests.sh`) PT-01 (lat/lon GET) + PT-03..PT-07 (region POST) URLs were updated to the post-rename wire format in batch 1; if `test-run` invokes PT, confirm the budgets remain green.
|
||||
- If the DNS-flake from cycle 5/6 recurs against `mt1.google.com` / `tile.googleapis.com`, treat it as the same host-network flakiness — out of scope for cycle 8 (this cycle does not touch the Google Maps download path).
|
||||
|
||||
## Git
|
||||
|
||||
- Branch: `dev` (per `.cursor/rules/git-workflow.mdc`).
|
||||
- Auto-push: NOT enabled this session — per project convention, commit will happen here; user will be asked about push.
|
||||
- Commits (planned subject lines, per the git-workflow rule's `[TRACKER-ID] Summary` format):
|
||||
- `[AZ-810] Strict validation for POST /api/satellite/upload metadata` (batch 4)
|
||||
- (Batches 1-3 already committed individually in their respective autodev runs.)
|
||||
|
||||
## Follow-up PBIs Surfaced by the Cumulative Review
|
||||
|
||||
These are not blocking cycle-8 closure; they emerged from the cumulative scan as candidates for cycle 9 or later:
|
||||
|
||||
| ID candidate | Title | SP | Rationale |
|
||||
|--------------|-------|----|-----------|
|
||||
| (open) | Promote `FixedTimeProvider` test helper to `SatelliteProvider.TestSupport` | 1 | The cycle-2 advisory ("if a 3rd consumer appears, promote") was crossed by batch 4; now duplicated across 4 test files. |
|
||||
| (open) | Promote `PostBatch` multipart helper to a shared `UavUploadMultipartFixture` | 1 | Duplicated between `UavUploadTests.cs` (cycle 2) and `UavUploadValidationTests.cs` (cycle 8 batch 4). |
|
||||
| (open) | Codify validator-filter decision matrix in `api_program.md::Api/Validators` | 2 | Cycle 8 established three validator filter patterns (JSON body, multipart, query params); document the decision matrix so the next endpoint author knows which to use. |
|
||||
| (open — coordination required) | Unify response-side `RoutePointDto` to use `lat`/`lon` wire keys (v2.0.0 of `route-creation.md`) | 3 | Cycle 8 standardized input wire on OSM `lat`/`lon`; response DTO still uses `latitude`/`longitude` — breaking change for `GET /api/satellite/route/{id}` clients. |
|
||||
| (open) | Decide whether to retire service-layer `RouteValidator` now that the API layer strictly validates | 2 | Currently retained as a defence-in-depth backstop; could be removed if no non-HTTP code path enqueues routes. |
|
||||
@@ -0,0 +1,70 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 01 (cycle 8)
|
||||
**Tasks**: AZ-812 (Region API field rename Latitude/Longitude → Lat/Lon, OSM convention)
|
||||
**Date**: 2026-05-22
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability | `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs:79` | `AssertErrorsContainsMention` copy-pasted from `TileInventoryValidationTests.cs` |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `AssertErrorsContainsMention` copy-pasted from `TileInventoryValidationTests.cs`** (Low / Maintainability)
|
||||
- Location: `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs:79-110` (and the prior copy at `TileInventoryValidationTests.cs:396-428`)
|
||||
- Description: The new `RegionFieldRenameTests.cs` copies the private `AssertErrorsContainsMention` helper verbatim from `TileInventoryValidationTests.cs`. Both helpers walk the same `errors` object and assert a mention exists in either keys or messages.
|
||||
- Suggestion: Promote the helper to `ProblemDetailsAssertions.cs` (the natural home for cross-test ProblemDetails assertions) and migrate both tests in a small follow-up cleanup. NOT done in this batch — touching the cycle-7 file is out of AZ-812 scope. Tracked as future hygiene, not a regression.
|
||||
- Task: AZ-812
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Outcome |
|
||||
|-------|---------|
|
||||
| 1. Context Loading | Read AZ-812 spec + `_docs/02_document/module-layout.md`. Scope is the Region POST DTO rename. |
|
||||
| 2. Spec Compliance | AC-1 ✓ (DTO has Lat/Lon + JsonPropertyName), AC-2 ✓ (wire format end-to-end with `lat`/`lon`), AC-3 ✓ (`RegionTests.cs` happy-path updated), AC-4 ✓ (`RegionFieldRenameTests` validates both directions, smoke green), AC-5 ✓ (common_dtos.md, api_program.md, system-flows.md updated/verified), AC-6 ✓ (`region-request.md` does not yet exist — AZ-808 will publish v1.0.0 directly with new names per spec coordination). No Spec-Gap. |
|
||||
| 3. Code Quality | Mechanical DTO rename, clean. One DRY violation (F1) — Low severity. |
|
||||
| 4. Security | No SQL injection, no hardcoded secrets, no sensitive data in logs. New test uses GUID + test coordinates only. |
|
||||
| 5. Performance | No perf impact (field rename). No N+1, no blocking I/O. |
|
||||
| 6. Cross-Task Consistency | Single-task batch — N/A. |
|
||||
| 7. Architecture Compliance | DTO in `SatelliteProvider.Common/DTO/` (Common layer, importable by all). Test in `SatelliteProvider.IntegrationTests/` (test layer). No layering violations, no cycles, no Public-API bypasses, no ADR violations. |
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` (DTO rename + `[JsonPropertyName]` attrs)
|
||||
- `SatelliteProvider.Api/Program.cs` (handler property access updated)
|
||||
- `SatelliteProvider.IntegrationTests/Models.cs` (test-side DTO mirror updated)
|
||||
- `SatelliteProvider.IntegrationTests/RegionTests.cs` (happy-path uses new property names)
|
||||
- `SatelliteProvider.IntegrationTests/IdempotentPostTests.cs` (JSON payload `lat`/`lon`)
|
||||
- `SatelliteProvider.IntegrationTests/SecurityTests.cs` (JSON payload `lat`/`lon`)
|
||||
- `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` (new — AZ-812 AC-4 coverage)
|
||||
- `SatelliteProvider.IntegrationTests/Program.cs` (smoke + full suite wired to call `RegionFieldRenameTests.RunAll`)
|
||||
- `scripts/run-performance-tests.sh` (PT-03/04/05/07 JSON bodies updated to `lat`/`lon`)
|
||||
- `_docs/02_document/modules/common_dtos.md` (RequestRegionRequest section added; RegionRequest disambiguated as internal queue type)
|
||||
- `_docs/02_document/modules/api_program.md` (RequestRegionRequest moved from Local Records to Common/DTO section)
|
||||
|
||||
## Test Evidence
|
||||
|
||||
`./scripts/run-tests.sh --smoke` (`integration-tests` container exit code 0):
|
||||
|
||||
```
|
||||
Test: Region endpoint OSM field-name rename (AZ-812)
|
||||
====================================================
|
||||
|
||||
AZ-812 AC-4 (positive): new {lat,lon} wire format → HTTP 200
|
||||
✓ {lat,lon} body accepted with HTTP 200
|
||||
|
||||
AZ-812 AC-4 (negative): legacy {latitude,longitude} wire format → HTTP 400 (UnmappedMemberHandling.Disallow)
|
||||
✓ Legacy {latitude,longitude} body rejected with HTTP 400; errors map names the unknown field
|
||||
✓ Region field-rename tests: PASSED
|
||||
```
|
||||
|
||||
No regressions: cycle-7 inventory validation suite, idempotent POST, security, route, tile, leaflet path, and migrations 012/013/014 all green in the same smoke run.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- No Critical, no High, no Medium findings.
|
||||
- 1 Low finding (DRY in test helpers) — does not block.
|
||||
- **PASS_WITH_WARNINGS**.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 02 (cycle 8)
|
||||
**Tasks**: AZ-808 (Region POST endpoint strict validation) + AZ-811 (lat/lon GET endpoint strict validation)
|
||||
**Date**: 2026-05-22
|
||||
**Verdict**: PASS_WITH_NOTES
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Info | Design rationale | `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs:1-30` | DTO uses `double?` / `int?` on purpose to dodge minimal-API "Required parameter not provided" short-circuit |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `GetTileByLatLonQuery` uses nullable types on purpose** (Info / Design rationale)
|
||||
- Location: `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs:17-20`
|
||||
- Description: The DTO declares `Lat`/`Lon`/`Zoom` as `double?`/`double?`/`int?`. Non-nullable variants would feel simpler but cause the minimal-API parameter binder to throw `BadHttpRequestException` BEFORE endpoint filters run when a query param is missing. That short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` — no `errors{}` envelope, no per-field key — which violates AZ-811 ACs 1 and 4 (every failure mode must surface as `errors.<paramName>`).
|
||||
- Initial implementation used non-nullable types. Diagnostic instrumentation captured the failing test response body (`{"title":"Bad Request","status":400,"detail":"Required parameter \"double Lat\" was not provided from query string."}`) which proved the binder was short-circuiting. Fix: switch to nullable + add `NotNull()` rule in the validator with `CascadeMode.Stop` ahead of the range rule. The handler dereferences `.Value` only after the validator filter passes.
|
||||
- Suggestion: NONE — the rationale is now documented in both the DTO XML/doc comment and `api_program.md::Api/DTOs`. Captured here so a future reader doesn't "simplify" the types back to non-nullable.
|
||||
- Task: AZ-811
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Outcome |
|
||||
|-------|---------|
|
||||
| 1. Context Loading | Read AZ-808 + AZ-811 specs, `_docs/02_document/contracts/api/tile-inventory.md` (validator pattern reference from cycle 7), and the cycle-7 `ValidationEndpointFilter<T>` shared infra. Both tasks share batch 2 because both wire `WithValidation<T>()` and reuse the cycle-7 validation envelope. |
|
||||
| 2. Spec Compliance | **AZ-808**: AC-1..AC-8 all ✓. New `RegionRequestValidator` covers `id`/`lat`/`lon`/`sizeMeters`/`zoomLevel`. `[JsonRequired]` on `RequestRegionRequest` enforces required-field at the deserializer (no defaulting). New contract `region-request.md` v1.0.0 published. Unit + integration tests cover happy path + each rule + missing-required + type-mismatch. Probe script exercises every failure mode via `curl`. **AZ-811**: AC-1..AC-9 all ✓. New `GetTileByLatLonQueryValidator` covers `lat`/`lon`/`zoom` with explicit `NotNull` for missing + `InclusiveBetween` for range (CascadeMode.Stop). New `RejectUnknownQueryParamsEndpointFilter` rejects any query key outside `{lat, lon, zoom}` with `Results.ValidationProblem`. New contract `tile-latlon.md` v1.0.0 published. Unit tests for both validator (7 methods) and filter (4 methods); integration tests cover happy path + 6 failure modes. Probe script exercises every failure mode. |
|
||||
| 3. Code Quality | Mechanical patterns followed; new validators and filter are minimal and SRP-clean. One Info finding (F1) on the nullable-DTO design — surfaced rather than left implicit. Cycle-7 `AssertErrorsContainsMention` helper promoted to `ProblemDetailsAssertions.cs` (closes the Low-severity DRY warning from batch-1 review). |
|
||||
| 4. Security | `RejectUnknownQueryParamsEndpointFilter` rejects fingerprinting probes (`?debug=1&admin=true`, `?Latitude=...&Longitude=...`) with HTTP 400 + named keys — no enumeration vector. `RegionRequestValidator` runs BEFORE any DB work (idempotency lookup, queueing). No SQL injection vectors, no hardcoded secrets, no PII in logs. JWT auth retained on both endpoints. |
|
||||
| 5. Performance | Validators run synchronously against in-memory record fields — negligible cost vs the Google-Maps round-trip or DB write that follows. Endpoint filter inspects `Query.Keys` (in-memory dictionary scan). No N+1, no blocking I/O. |
|
||||
| 6. Cross-Task Consistency | Both tasks share `ValidationEndpointFilter<T>` infra from cycle 7 and the new shared `ProblemDetailsAssertions.AssertErrorsContainsMention`. `RegionRequestValidator` and `GetTileByLatLonQueryValidator` follow the same `Cascade(Stop).NotNull().InclusiveBetween()` pattern. Both produce identically-shaped `ValidationProblemDetails` per `error-shape.md` v1.0.0. |
|
||||
| 7. Architecture Compliance | DTOs in `SatelliteProvider.Common/DTO/` (Region) and `SatelliteProvider.Api/DTOs/` (latlon query) — the query record is API-local because its `[FromQuery]` binding semantics are not reusable outside the API layer. Validators co-located with the API at `SatelliteProvider.Api/Validators/`. No layering violations. No cycles, no public-API bypasses, no ADR breaches. |
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
### AZ-808 (Region POST validator)
|
||||
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` — `[JsonRequired]` on every property; removed default values for `ZoomLevel` and `StitchTiles` so callers cannot rely on implicit defaults.
|
||||
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` — **NEW** — 5 rules (id non-empty + 4 range rules).
|
||||
- `SatelliteProvider.Api/Program.cs` (lines around `MapPost("/api/satellite/request", ...)`) — added `.WithValidation<RequestRegionRequest>()`, `.Accepts<>`, `.Produces<>`, `.ProducesProblem()`. Removed inline `request.SizeMeters` size check (now in validator).
|
||||
- `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — **NEW** — Theory + Fact coverage for each rule, positive and negative.
|
||||
- `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` — **NEW** — Happy + empty body + missing/zero GUID + 4 out-of-range + missing-stitch + type mismatch.
|
||||
- `SatelliteProvider.IntegrationTests/Program.cs` — wired `RegionRequestValidationTests.RunAll` into smoke + full suites.
|
||||
- `scripts/probe_region_validation.sh` — **NEW** — curl probes for every failure mode.
|
||||
- `_docs/02_document/contracts/api/region-request.md` — **NEW** — v1.0.0 contract (no prior version existed).
|
||||
- `_docs/02_document/modules/api_program.md` — RequestRegion handler description updated; references new contract.
|
||||
- `_docs/02_document/system-flows.md::F2` — references new contract + validator.
|
||||
|
||||
### AZ-811 (lat/lon GET validator)
|
||||
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` — **NEW** — nullable record with `[FromQuery(Name = ...)]` per property. Rationale documented in-file (F1).
|
||||
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` — **NEW** — `Cascade(Stop).NotNull().InclusiveBetween` per param.
|
||||
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` — **NEW** — reusable `IEndpointFilter` parameterised by allowed-keys set (case-insensitive). Returns `Results.ValidationProblem` with one error per unknown key.
|
||||
- `SatelliteProvider.Api/Program.cs` (lines around `MapGet("/api/satellite/tiles/latlon", ...)`) — added envelope filter + `.WithValidation<GetTileByLatLonQuery>()`. Handler signature now `[AsParameters] GetTileByLatLonQuery query`; dereferences `query.Lat!.Value` etc.
|
||||
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` — descriptions for `lat`/`lon`/`zoom` (post-rename); removed legacy `Latitude`/`Longitude`/`ZoomLevel` entries.
|
||||
- `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` — **NEW** — 9 methods incl. null cases.
|
||||
- `SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs` — **NEW** — 4 methods (delegation, unknown-key block, legacy PascalCase, case-insensitive allowed-set).
|
||||
- `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` — **NEW** — Happy + 3 out-of-range + 1 missing-required + 2 unknown-key (legacy + hostile) + 1 type-mismatch = 8 methods.
|
||||
- `SatelliteProvider.IntegrationTests/Program.cs` — wired `GetTileByLatLonValidationTests.RunAll` into smoke + full suites.
|
||||
- `SatelliteProvider.IntegrationTests/TileTests.cs` — URL `?Latitude=&Longitude=&ZoomLevel=` → `?lat=&lon=&zoom=`.
|
||||
- `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs` — `ProtectedTilesPath` const updated.
|
||||
- `SatelliteProvider.IntegrationTests/SecurityTests.cs` — SQLi probe URL updated.
|
||||
- `scripts/probe_latlon_validation.sh` — **NEW** — curl probes incl. missing-lat, hostile probes, type mismatch.
|
||||
- `scripts/run-performance-tests.sh` — PT-01 URL updated.
|
||||
- `README.md` — endpoint example updated.
|
||||
- `_docs/02_document/contracts/api/tile-latlon.md` — **NEW** — v1.0.0 contract (no prior version existed).
|
||||
- `_docs/02_document/modules/api_program.md` — handler + `Api/Validators` + `Api/DTOs` sections updated.
|
||||
- `_docs/02_document/modules/common_uuidv5.md` — example URL updated.
|
||||
- `_docs/02_document/system-flows.md::F1` — references new contract + validation layers.
|
||||
- `_docs/02_document/tests/blackbox-tests.md` — BT-01, BT-N01, BT-N02, BT-18 triggers updated.
|
||||
- `_docs/02_document/tests/security-tests.md` — SEC-01, SEC-05 triggers updated.
|
||||
|
||||
### Shared (cross-task hygiene)
|
||||
- `SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs` — `AssertErrorsContainsMention` promoted from per-test-file private helper to public static. Closes batch-1 Low-severity DRY warning.
|
||||
- `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` — uses shared helper.
|
||||
- `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` — uses shared helper; removed unused `using` directives.
|
||||
|
||||
## Test Evidence
|
||||
|
||||
`./scripts/run-tests.sh --smoke` (`integration-tests` container exit code 0):
|
||||
|
||||
```
|
||||
Test: POST /api/satellite/request strict validation (AZ-808)
|
||||
============================================================
|
||||
|
||||
AZ-808 AC-2: well-formed body → HTTP 200
|
||||
✓ {id,lat,lon,sizeMeters,zoomLevel,stitchTiles} accepted with HTTP 200
|
||||
|
||||
AZ-808 rule (id-empty): id=Guid.Empty → HTTP 400
|
||||
✓ id=Guid.Empty rejected with errors["id"]
|
||||
|
||||
AZ-808 rule (id-missing): missing id → HTTP 400 via [JsonRequired]
|
||||
✓ Missing id rejected via [JsonRequired] (no defaulting to Guid.Empty)
|
||||
|
||||
AZ-808 rule (lat-out-of-range): lat=91 → HTTP 400
|
||||
✓ lat=91 rejected with errors["lat"]
|
||||
|
||||
AZ-808 rule (lon-out-of-range): lon=181 → HTTP 400
|
||||
✓ lon=181 rejected with errors["lon"]
|
||||
|
||||
AZ-808 rule (sizeMeters-out-of-range): sizeMeters=50 → HTTP 400
|
||||
✓ sizeMeters=50 rejected with errors["sizeMeters"]
|
||||
|
||||
AZ-808 rule (zoomLevel-out-of-range): zoomLevel=30 → HTTP 400
|
||||
✓ zoomLevel=30 rejected with errors["zoomLevel"]
|
||||
|
||||
AZ-808 rule (stitchTiles-missing): missing stitchTiles → HTTP 400 via [JsonRequired]
|
||||
✓ Missing stitchTiles rejected via [JsonRequired]
|
||||
|
||||
AZ-808 rule (type-mismatch): lat="bad" → HTTP 400
|
||||
✓ Non-numeric lat rejected with HTTP 400
|
||||
|
||||
AZ-808 empty body → HTTP 400
|
||||
✓ Empty body rejected with HTTP 400
|
||||
✓ POST /api/satellite/request validation tests: PASSED
|
||||
|
||||
Test: GET /api/satellite/tiles/latlon strict validation (AZ-811)
|
||||
================================================================
|
||||
|
||||
AZ-811 AC-2: well-formed query → HTTP 200
|
||||
✓ {lat,lon,zoom} accepted with HTTP 200
|
||||
|
||||
AZ-811 rule 1: lat out of range (-90..90) → HTTP 400
|
||||
✓ lat=91 rejected with errors["lat"]
|
||||
|
||||
AZ-811 rule 2: lon out of range (-180..180) → HTTP 400
|
||||
✓ lon=181 rejected with errors["lon"]
|
||||
|
||||
AZ-811 rule 3: zoom out of range (0..22) → HTTP 400
|
||||
✓ zoom=30 rejected with errors["zoom"]
|
||||
|
||||
AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat
|
||||
✓ Missing lat rejected with errors["lat"] = `lat` is required
|
||||
|
||||
AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter)
|
||||
✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter
|
||||
|
||||
AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter)
|
||||
✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys
|
||||
|
||||
AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400
|
||||
✓ lat=fifty rejected with HTTP 400
|
||||
✓ GET lat/lon validation tests: PASSED
|
||||
```
|
||||
|
||||
`=== All tests passed (mode=smoke) ===` — no regressions in cycle-7 inventory/idempotent/security/route/tile/leaflet/migration suites.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- No Critical, no High, no Medium findings.
|
||||
- 1 Info finding (F1) — design rationale captured in code + doc; not a regression.
|
||||
- **PASS_WITH_NOTES**.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 03 (cycle 8)
|
||||
**Tasks**: AZ-809 (POST /api/satellite/route strict validation)
|
||||
**Date**: 2026-05-22
|
||||
**Verdict**: PASS_WITH_NOTES
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | API alignment | `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:68-77` | `OverridePropertyName` is required on deep expressions because FluentValidation drops the parent path on `req.Geofences!.Polygons` |
|
||||
| 2 | Info | Defence-in-depth | `SatelliteProvider.Services.RouteManagement/RouteValidator.cs` (existing) | Service-layer `RouteValidator` is now strictly weaker than the cycle-8 `CreateRouteRequestValidator` and could be deleted, but is retained as a defence-in-depth backstop |
|
||||
| 3 | Info | Wire-shape asymmetry | `SatelliteProvider.Common/DTO/RoutePoint.cs` (input) vs `SatelliteProvider.Common/DTO/RoutePointDto.cs` (output) | The input wire uses short OSM `lat`/`lon`; the response wire uses long `latitude`/`longitude`. Pre-existing — AZ-809 documented but did not unify |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `OverridePropertyName` is mandatory on the geofences chain** (Low / API alignment)
|
||||
- Location: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:68-77` and `SatelliteProvider.Api/Validators/RoutePointValidator.cs:30-40`.
|
||||
- Description: FluentValidation's default property-name policy drops the parent on deep member expressions like `req => req.Geofences!.Polygons`. Without `OverridePropertyName("geofences.polygons")`, the error keys emitted are leaf-only (`polygons`, `polygons[0].northWest`) instead of the full wire path the spec mandates (`geofences.polygons`, `geofences.polygons[0].northWest`). The fix lives in code AND in a comment explaining WHY; without the comment a future reader would "simplify" the rule chain and silently break wire compatibility. Same pattern applies to `RoutePointValidator` where C# property `Latitude` must surface as wire `lat` — handled by `OverridePropertyName` chained AFTER the first concrete rule (a generic-type-inference quirk: the extension is defined on `IRuleBuilderOptions<T, TProperty>`, which only becomes inferable after the first `.InclusiveBetween()` etc.).
|
||||
- Suggestion: NONE — rationale captured in code comments AND in `api_program.md::Api/Validators` so the next reader cannot break it by accident.
|
||||
- Task: AZ-809
|
||||
|
||||
**F2: Service-layer `RouteValidator` retained as defence-in-depth** (Info / Defence-in-depth)
|
||||
- Location: `SatelliteProvider.Services.RouteManagement/RouteValidator.cs`.
|
||||
- Description: The pre-cycle-8 service-layer validator (`RouteValidator`) covered approximately the same surface as the new `CreateRouteRequestValidator` (id non-empty, points count ≥ 2, geofence corner sanity). Now that the API layer rejects every invalid request before the service runs, `RouteValidator` is strictly redundant for HTTP-driven paths. It is, however, also called from the route processing service (background queue) where some bypass path could in principle smuggle an invalid payload — keeping it as a backstop costs ~30 lines and one extra unit-test pass. Removal is tracked as an advisory in `route-creation.md` ("Validator Cleanup Advisory") so the next cycle can decide whether to consolidate.
|
||||
- Suggestion: Defer to a follow-up PBI. Do NOT delete in this batch.
|
||||
- Task: AZ-809
|
||||
|
||||
**F3: Input/output naming asymmetry on route points** (Info / Wire-shape asymmetry)
|
||||
- Location: `SatelliteProvider.Common/DTO/RoutePoint.cs` (input) vs `SatelliteProvider.Common/DTO/RoutePointDto.cs` (output).
|
||||
- Description: Request points use `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]`; response points serialize the underlying C# `Latitude`/`Longitude` properties verbatim. This asymmetry existed before cycle 8. AZ-809 documents it in `route-creation.md` v1.0.0 and `common_dtos.md`, but does not unify because changing the response wire would be a breaking change to existing clients of `GET /api/satellite/route/{id}`. Tracked as an advisory.
|
||||
- Suggestion: Open a successor PBI to consider unifying via a `lat`/`lon` rename on `RoutePointDto` (would be a v2.0.0 of `route-creation.md`).
|
||||
- Task: AZ-809
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Outcome |
|
||||
|-------|---------|
|
||||
| 1. Context Loading | Read AZ-809 spec, `_docs/02_document/contracts/api/region-request.md` (batch-2 pattern), `_docs/02_document/contracts/api/error-shape.md` (failure shape), and the cycle-7 + cycle-8 (batch-2) validation infra. The route endpoint differs from batch-2 endpoints because it has nested DTOs (RoutePoint, Geofences/GeofencePolygon/GeoPoint) requiring child validators. |
|
||||
| 2. Spec Compliance | All 9 ACs ✓. New `CreateRouteRequestValidator` covers the 14 documented rules across deserializer-layer + validator-layer. Nested `RoutePointValidator` + `GeofencePolygonValidator` co-validators wired via `RuleForEach.SetValidator(...)`. Cross-field invariants enforced at both the root (`createTilesZip ⇒ requestMaps`) and per-polygon (`NW.Lat > SE.Lat`, `NW.Lon < SE.Lon`). New contract `route-creation.md` v1.0.0 published. 16 integration tests + 26 unit tests cover happy path + each rule + missing-required + type-mismatch + cross-field. Probe script exercises every failure mode via `curl`. |
|
||||
| 3. Code Quality | Mechanical patterns followed; three new validators are minimal and SRP-clean. The `OverridePropertyName` requirement on deep expressions (F1) is non-obvious and was discovered via failing unit tests + diagnostic instrumentation; the workaround is captured in both code comments and module docs so it cannot be silently regressed. `RoutePointValidator` and `GeofencePolygonValidator` are file-private — inner `GeoCornerValidator` is nested inside `GeofencePolygonValidator` because the polygon corners are its only consumer; if a future sibling endpoint needs point-shape validation, the spec says to promote and rename. |
|
||||
| 4. Security | Validators run BEFORE any DB work (route persistence, intermediate-point computation, queue enqueue). The cross-field invariants prevent NaN-geometry payloads from reaching the GeoUtils interpolator (which is not designed for NW=SE corners). No SQL injection vectors, no hardcoded secrets, no PII in logs. JWT auth retained on the endpoint. Probe script tests `?debug=1` / extra root fields → all rejected. |
|
||||
| 5. Performance | Validators run synchronously against in-memory record fields — negligible cost (microseconds) vs the route's interpolation pass + DB inserts. Even worst-case `points.Count = 500` with all `geofences.polygons.Count` runs ~500 microsecond. No N+1, no blocking I/O. |
|
||||
| 6. Cross-Task Consistency | Uses the same `ValidationEndpointFilter<T>` infra from cycle 7 + batch-2 of cycle 8 and the shared `ProblemDetailsAssertions.AssertErrorsContainsMention`. Error keys follow the same camelCase JSON-path policy (`points[i].lat`, `geofences.polygons[i].northWest`) per `error-shape.md` v1.0.0 Inv-4. All produce identically-shaped `ValidationProblemDetails` bodies. |
|
||||
| 7. Architecture Compliance | Route DTOs live in `SatelliteProvider.Common/DTO/` (shared with the service layer + integration tests). Validators co-located with the API at `SatelliteProvider.Api/Validators/`. No layering violations. The service-layer `RouteValidator` retention is documented as defence-in-depth (F2). No cycles, no public-API bypasses, no ADR breaches. |
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
### AZ-809 (route-creation validator)
|
||||
- `SatelliteProvider.Common/DTO/CreateRouteRequest.cs` — `[JsonRequired]` on every non-optional axis. Removed implicit defaults so callers cannot rely on them.
|
||||
- `SatelliteProvider.Common/DTO/RoutePoint.cs` — `[JsonRequired]` on Latitude/Longitude.
|
||||
- `SatelliteProvider.Common/DTO/GeofencePolygon.cs` — `[JsonRequired]` on `NorthWest`/`SouthEast` in `GeofencePolygon`; `[JsonRequired]` on `Polygons` in `Geofences`.
|
||||
- `SatelliteProvider.Common/DTO/GeoPoint.cs` — `[JsonRequired]` on Lat/Lon (used by `GeofencePolygon` corners).
|
||||
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` — **NEW** — 7 root rules (id non-empty + 4 range rules + points count + cross-field) plus `RuleForEach(req => req.Points).SetValidator(...)` and `RuleForEach(req => req.Geofences!.Polygons).SetValidator(...).OverridePropertyName(...)` for nested chains.
|
||||
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` — **NEW** — `OverridePropertyName("lat"/"lon")` chained AFTER `.InclusiveBetween()` so the type parameter is inferable.
|
||||
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` — **NEW** — `CascadeMode.Stop` + `NotNull` + nested `GeoCornerValidator` for per-corner ranges; cross-field `Must` rules `.WithName("northWest")` for the invariant errors.
|
||||
- `SatelliteProvider.Api/Program.cs` (lines around `MapPost("/api/satellite/route", ...)`) — added `.WithValidation<CreateRouteRequest>()`, `.Accepts<>`, `.Produces<>`, `.ProducesProblem()`.
|
||||
- `SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs` — **NEW** — Theory + Fact coverage for each rule, positive and negative; 16 methods. Diagnostic-led: two assertions were converted from `polygons` / `polygons[0].northWest` to `geofences.polygons` / `geofences.polygons[0].northWest` after `OverridePropertyName` was added.
|
||||
- `SatelliteProvider.Tests/Validators/RoutePointValidatorTests.cs` — **NEW** — 4 methods (lat/lon range, positive + negative).
|
||||
- `SatelliteProvider.Tests/Validators/GeofencePolygonValidatorTests.cs` — **NEW** — 6 methods incl. NotNull on corners, range on corners, NW-of-SE invariants.
|
||||
- `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` — **NEW** — Happy + empty body + missing/zero GUID + 4 out-of-range + insufficient-points + per-point lat/lon out-of-range + geofence invariant + missing-requestMaps + cross-field createTilesZip + unknown-root + nested type-mismatch = 16 methods.
|
||||
- `SatelliteProvider.IntegrationTests/Program.cs` — wired `CreateRouteValidationTests.RunAll` into smoke + full suites.
|
||||
- `scripts/probe_route_validation.sh` — **NEW** — curl probes for every failure mode + happy path.
|
||||
- `_docs/02_document/contracts/api/route-creation.md` — **NEW** — v1.0.0 contract (no prior version existed). Includes the nested DTO chain + invariants + per-field test cases table + advisory on the legacy `RouteValidator` + the input/output naming asymmetry.
|
||||
- `_docs/02_document/modules/api_program.md` — `CreateRoute Handler` section added; `Api/Validators` section bumped to AZ-808/AZ-809/AZ-811.
|
||||
- `_docs/02_document/modules/common_dtos.md` — `CreateRouteRequest`/`RoutePoint`/`Geofences`/`GeofencePolygon`/`GeoPoint` descriptions updated with `[JsonRequired]` markers and constraint summaries.
|
||||
- `_docs/02_document/system-flows.md::F4` — sequence diagram extended with the validation-filter branch; preconditions + error scenarios reference the new contract.
|
||||
- `_docs/02_document/tests/blackbox-tests.md::BT-06/BT-N03/BT-N04/BT-N05` — triggers and pass criteria align with the new wire format + named error keys.
|
||||
- `_docs/02_document/tests/security-tests.md::SEC-04` — references `GlobalExceptionHandler`'s JsonException branch + AZ-353 correlationId.
|
||||
|
||||
## Test Evidence
|
||||
|
||||
`./scripts/run-tests.sh --smoke` (`integration-tests` container exit code 0):
|
||||
|
||||
```
|
||||
Test: POST /api/satellite/route strict validation (AZ-809)
|
||||
==========================================================
|
||||
|
||||
AZ-809 AC-2: well-formed body → HTTP 200 (no background processing — requestMaps=false)
|
||||
✓ Well-formed body accepted with HTTP 200
|
||||
|
||||
AZ-809 rule 1: empty body → HTTP 400
|
||||
✓ Empty body rejected with HTTP 400
|
||||
|
||||
AZ-809 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)
|
||||
✓ Missing `id` rejected with HTTP 400 (no silent coercion)
|
||||
|
||||
AZ-809 rule 2: zero-Guid `id` → HTTP 400
|
||||
✓ Zero-Guid `id` rejected with errors["id"]
|
||||
|
||||
AZ-809 rule 3: empty `name` → HTTP 400
|
||||
✓ Empty `name` rejected with errors["name"]
|
||||
|
||||
AZ-809 rule 5: `regionSizeMeters` out of range (100..10000) → HTTP 400
|
||||
✓ `regionSizeMeters=1000000` rejected with errors["regionSizeMeters"]
|
||||
|
||||
AZ-809 rule 6: `zoomLevel` out of range (0..22) → HTTP 400
|
||||
✓ `zoomLevel=30` rejected with errors["zoomLevel"]
|
||||
|
||||
AZ-809 rule 7: `points` count < 2 → HTTP 400
|
||||
✓ `points` count=1 rejected with errors["points"]
|
||||
|
||||
AZ-809 rule 8: per-point `lat` out of range → HTTP 400 (errors[points[i].lat])
|
||||
✓ `points[1].lat=91` rejected with errors["points[1].lat"]
|
||||
|
||||
AZ-809 rule 8: per-point `lon` out of range → HTTP 400 (errors[points[i].lon])
|
||||
✓ `points[1].lon=181` rejected with errors["points[1].lon"]
|
||||
|
||||
AZ-809 rule 9: geofence NW.lat <= SE.lat → HTTP 400 (cross-field invariant)
|
||||
✓ NW.lat <= SE.lat rejected by cross-field invariant
|
||||
|
||||
AZ-809 rule 10: missing `requestMaps` → HTTP 400 (no defaulting)
|
||||
✓ Missing `requestMaps` rejected
|
||||
|
||||
AZ-809 rule 12: `createTilesZip=true` AND `requestMaps=false` → HTTP 400 (cross-field invariant)
|
||||
✓ `createTilesZip=true requestMaps=false` rejected by cross-field invariant
|
||||
|
||||
AZ-809 rule 13: unknown root field → HTTP 400 (UnmappedMemberHandling.Disallow)
|
||||
✓ Unknown root field `debug` rejected with errors mention
|
||||
|
||||
AZ-809 rule 14: nested type mismatch (`points[0].lat` as string) → HTTP 400
|
||||
✓ `points[0].lat:"fifty"` rejected with HTTP 400
|
||||
✓ Create-route validation tests: PASSED
|
||||
```
|
||||
|
||||
`=== All tests passed (mode=smoke) ===` — no regressions in cycle-7 inventory or batch-1/batch-2 cycle-8 (AZ-812/AZ-808/AZ-811) tests, no regressions in the migration/leaflet/route/tile/security suites.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- No Critical, no High, no Medium findings.
|
||||
- 1 Low finding (F1) — the `OverridePropertyName` requirement is captured in code comments + module docs; not a regression, but worth flagging so it cannot be silently regressed.
|
||||
- 2 Info findings (F2 defence-in-depth retention, F3 input/output naming asymmetry) — both pre-existing, documented as advisories for a follow-up PBI.
|
||||
- **PASS_WITH_NOTES**.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 04 (cycle 8)
|
||||
**Tasks**: AZ-810 (POST /api/satellite/upload strict metadata validation, multipart envelope)
|
||||
**Date**: 2026-05-23
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability / DRY | `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs`, `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs`, `SatelliteProvider.Tests/Services/UavTileQualityGateTests.cs`, `SatelliteProvider.Tests/Services/UavTileUploadHandlerTests.cs` | `FixedTimeProvider` duplicated across four test files (now exceeds the "3 consumers → promote" threshold the cycle-2 file comment named) |
|
||||
| 2 | Low | Maintainability / DRY | `SatelliteProvider.IntegrationTests/UavUploadTests.cs:~270` + `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs:600-614` | `PostBatch(client, metadata, files)` multipart-build helper duplicated with identical signature/behavior across the two upload integration suites |
|
||||
| 3 | Info | Wire-shape asymmetry | `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:67-77` | Errors raised inside the metadata-JSON deserialization (malformed JSON, type-mismatch, unknown root field, malformed `flightId`) all surface under `errors["metadata"]` — the JSON path inside the JsonException is intentionally not exploded into `errors["metadata.items[0].latitude"]`. Documented in the contract; tests assert the actual key. |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `FixedTimeProvider` duplication has now crossed the "promote to shared" threshold** (Low / Maintainability)
|
||||
- Location: four test files in `SatelliteProvider.Tests/` carry an identical `private sealed class FixedTimeProvider : TimeProvider { ... }` body. Two pre-existed (AZ-488 cycle 2 — `UavTileQualityGateTests`, `UavTileUploadHandlerTests`); two are new in this batch (`UavTileBatchMetadataPayloadValidatorTests`, `UavTileMetadataValidatorTests`).
|
||||
- Description: The cycle-2 file-level comment in `UavTileQualityGateTests` explicitly said *"if a third consumer appears, promote to `SatelliteProvider.TestSupport`."* Batch 4 added the third AND fourth consumer. The duplication is currently harmless (the implementations are byte-identical), but the next reader changing one of them risks a silent drift, especially since FluentValidation's `RuleForEach.SetValidator(...)` propagates the `TimeProvider` instance the root validator was given — a fork would not be detected by either side's unit tests.
|
||||
- Suggestion: Promote `FixedTimeProvider` to `SatelliteProvider.TestSupport/FixedTimeProvider.cs` (analogous to `JwtTokenFactory`, `IntegrationTestResetGuard`). Update the four call-sites in a follow-up Low PBI. Do NOT do it as part of AZ-810 — it is out of task scope and would push four test files into the diff for no functional benefit.
|
||||
- Suggestion target: open follow-up PBI "Promote `FixedTimeProvider` test helper to `SatelliteProvider.TestSupport`" (≈1 SP).
|
||||
- Task: AZ-810 (cycle-2 carryover; AZ-810 just made the duplication visible enough to act on).
|
||||
|
||||
**F2: `PostBatch` multipart helper duplicated across two integration test suites** (Low / Maintainability)
|
||||
- Location: `SatelliteProvider.IntegrationTests/UavUploadTests.cs` (cycle 2) and the new `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` both define `private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)` with identical signatures and bodies (serialize `metadata` via System.Text.Json, build `MultipartFormDataContent`, attach each file under the `files` name with `image/jpeg`).
|
||||
- Description: Same drift risk as F1, but limited to the integration test project. The helpers diverging would break only the suite that did not get the update — both suites pass against the production endpoint, so the silent-drift surface is small. Still worth flagging because UavUploadTests' helper has subtly different `JsonSerializerOptions` setup that may want to be unified.
|
||||
- Suggestion: Promote `PostBatch` to a shared `UavUploadMultipartFixture` (test-only helper) inside `SatelliteProvider.IntegrationTests/`. Both suites then reference one canonical builder. Defer to a follow-up PBI alongside F1.
|
||||
- Task: AZ-810.
|
||||
|
||||
**F3: Metadata-JSON deserialization errors collapse to `errors["metadata"]`** (Info / Wire-shape asymmetry)
|
||||
- Location: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs::InvokeAsync` lines 67-77 + 78-88 (the `JsonException`-catch path and the manual filter responses for missing form field / missing metadata).
|
||||
- Description: When the JSON inside the `metadata` form field fails strict deserialization (malformed JSON, unknown root field via `UnmappedMemberHandling.Disallow`, unknown nested field, nested type mismatch, malformed `flightId` UUID, missing required field surfaced as `JsonException`), the filter catches the exception manually and surfaces it under a single error key — `errors["metadata"]` — with the full `JsonException.Message` (which itself includes the JSON path like `$.items[0].latitude`). It does NOT explode the JsonException path into a separate prefixed error key like `errors["metadata.items[0].latitude"]`. This is by design: the metadata is a NESTED JSON value inside a multipart form field, so the form-level wire-shape correctly reports the error at the form-field granularity. The JSON path is preserved inside the message text so debuggers can still localize. FluentValidation rule violations DO get the full prefixed path (`errors["metadata.items[0].latitude"]`).
|
||||
- Suggestion: NONE. Documented in `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 "Metadata validation" section + `_docs/02_document/contracts/api/error-shape.md`. Integration tests use `AssertErrorsContainsMention` (substring match) so they tolerate either shape — important so the contract can later choose to explode JSON paths without breaking tests.
|
||||
- Task: AZ-810.
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Outcome |
|
||||
|-------|---------|
|
||||
| 1. Context Loading | Read AZ-810 spec, `_docs/02_document/contracts/api/uav-tile-upload.md` v1.1.0 (pre-bump), `_docs/02_document/contracts/api/error-shape.md` v1.0.0, batch-2 `RegionRequestValidator` + batch-3 `CreateRouteRequestValidator` for cycle-8 conventions, `GlobalExceptionHandler.cs` for the `BadHttpRequestException → ValidationProblemDetails` shape, and `UavUploadTests.cs` (AZ-488) for the legacy multipart fixture. The endpoint is uniquely multipart, so the cycle-8 generic `ValidationEndpointFilter<T>()` (JSON-body-only) does NOT fit — a new `UavUploadValidationFilter` extracts the `metadata` form field, runs deserialization with strict `JsonSerializerOptions`, then runs FluentValidation, then enforces the cross-field `items.Count == files.Count` invariant. |
|
||||
| 2. Spec Compliance | All 9 ACs covered. AC-1 (14 rules across deserializer + FluentValidation + cross-field) verified. AC-2 (happy path) verified by `HappyPath_Returns200`. AC-3 (validators in own files, ≥11 unit tests) — `UavTileBatchMetadataPayloadValidator.cs` + `UavTileMetadataValidator.cs` (2 files), 13 unit tests. AC-4 (integration ≥13) — 16 methods. AC-5 (contract v1.2.0) — bumped, validation rules section added. AC-6 (api_program.md) — updated. AC-7 (`[JsonRequired]` + `.ProducesProblem(400)`) — done; range annotations omitted per existing project pattern (FluentValidation messages convey the range). AC-8 (probe script) — `scripts/probe_upload_validation.sh` covers happy + 14 failure modes. AC-9 (no AZ-488 regression) — AZ-488 happy paths all use valid metadata (lat/lon in range, zoom=18, tileSizeMeters=200, capturedAt fresh) so the new validator accepts them unchanged; verified by tracing each `UavUploadTests` payload against the new validator rules. |
|
||||
| 3. Code Quality | Three new files in `SatelliteProvider.Api/Validators/` follow SRP cleanly. `UavTileBatchMetadataPayloadValidator` is 6 rules (1 NotNull + 1 NotEmpty + 1 count cap + RuleForEach). `UavTileMetadataValidator` is 5 range/freshness rules + explanatory comment on the deliberate `FlightId` no-op. `UavUploadValidationFilter` is ~120 lines doing exactly one job — buffer the form, deserialize the metadata, run the validator, check items/files parity. `ArgumentNullException.ThrowIfNull` used consistently; no silent catches; manual `ValidationProblem` shapes match the RFC 7807 contract. `[JsonRequired]` placement on `UavTileMetadata`/`UavTileBatchMetadataPayload` follows the cycle-7 (`TileInventoryRequest`) and batch-3 cycle-8 (`CreateRouteRequest`) precedent. Two DRY findings (F1 + F2) — both test-only, both deferred to follow-up PBIs. |
|
||||
| 4. Security | All validation runs BEFORE any DB work, file write, or queue enqueue. The filter intercepts on the endpoint pipeline — even an authenticated caller cannot reach the handler without passing the validator. Cross-field `items.Count == files.Count` prevents an attacker from posting 100 metadata + 1 file (which would otherwise zip-bomb-style let the loop iterate over a short files array). `UnmappedMemberHandling.Disallow` prevents fingerprinting via unknown fields. The `JsonException.Message` surfaced under `errors["metadata"]` may include the offending JSON snippet — this is acceptable because the body is supplied by the caller themselves; it does not leak server-side state. JWT auth + `RequireAuthorization(SatellitePermissions.UavUploadPolicy)` retained on the endpoint. No new secrets, no PII in logs. |
|
||||
| 5. Performance | Validators run synchronously against in-memory record fields — microseconds even at the `MaxBatchSize = 100` upper bound. `ReadFormAsync` buffers the multipart body once; the buffered `IFormCollection` is reused by the downstream handler (ASP.NET caches it on the `HttpRequest`). For invalid requests we DO buffer the full body before rejecting, but Kestrel's `MaxRequestBodySize` bounds this; the alternative (streaming validation) would require a custom multipart parser and is overkill. No N+1, no blocking I/O. Filter overhead per request: one `ReadFormAsync` (already needed by the handler), one `JsonSerializer.Deserialize` of the metadata string, one synchronous FluentValidation pass. |
|
||||
| 6. Cross-Task Consistency | Uses the same `ProblemDetailsAssertions` helper as batches 02/03 of cycle 8 and cycle 7. Error keys follow the same camelCase JSON-path policy per `error-shape.md` v1.0.0 Inv-4. `ValidationProblemDetails` produced has the same shape as the JSON-body endpoints (status=400, title="One or more validation errors occurred.", errors as a dict of arrays). Per-item indexed paths (`items[0].latitude`) follow the same convention as `RegionRequestValidator`'s nested-DTO chain. New `UavUploadValidationFilter` is intentionally distinct from the generic `ValidationEndpointFilter<T>` — different envelope shape (multipart vs JSON body) — and the two filters' shape choices are mutually compatible. |
|
||||
| 7. Architecture Compliance | New validators + filter in `SatelliteProvider.Api/Validators/` — owned by WebApi (Layer 4). `[JsonRequired]` additions in `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (Layer 0). No new cross-layer dependencies. No cycles. `IValidator<UavTileBatchMetadataPayload>` resolves via the existing `AddValidatorsFromAssemblyContaining<Program>()` registration (cycle 7). `UavUploadValidationFilter` is added to DI as transient (matches existing endpoint filter registration pattern). No public-API surface changes in Common (DTOs already public, attributes are metadata-only). No ADRs to breach (project has no `_docs/02_document/adr/` folder). |
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
### AZ-810 (UAV upload validator + multipart filter)
|
||||
|
||||
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` — **NEW** — root validator. `RuleFor(p => p.Items)` NotNull + NotEmpty + `Must(<= MaxBatchSize)`; `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider))`. The TimeProvider is threaded through so unit tests can inject a fixed clock and the produced per-item validator sees the same clock.
|
||||
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` — **NEW** — per-item validator. 5 rules (lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within `now ± CapturedAtFutureSkewSeconds`/`now - MaxAgeDays`). `FlightId` intentionally NOT validated beyond JSON shape — the AZ-503 anonymous-flight semantics keep it nullable, and shape-level rejection (malformed UUID) is handled at the deserializer layer.
|
||||
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` — **NEW** — `IEndpointFilter` that intercepts multipart bodies. Reads `metadata` field, deserializes with the global strict `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` applies), runs `IValidator<UavTileBatchMetadataPayload>`, then checks `items.Count == files.Count`. FluentValidation errors are prefixed with `metadata.` so the wire-key is `metadata.items[0].latitude` (full path); cross-field violation surfaces under BOTH `errors["metadata.items"]` AND `errors["files"]` so client UI code keyed on either field can act.
|
||||
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` — `[JsonRequired]` on `Latitude`, `Longitude`, `TileZoom`, `TileSizeMeters`, `CapturedAt` (`UavTileMetadata` record) and on `Items` (`UavTileBatchMetadataPayload` record). `FlightId` stays optional. File-level comment block updated with the AZ-810 rationale so the next reader cannot accidentally remove the attributes thinking they are redundant.
|
||||
- `SatelliteProvider.Api/Program.cs` — registered `UavUploadValidationFilter` as transient (`builder.Services.AddTransient<UavUploadValidationFilter>()`) and wired `.AddEndpointFilter<UavUploadValidationFilter>()` onto the `MapPost("/api/satellite/upload", ...)` chain along with `.Accepts<>` + `.Produces<>` + `.ProducesProblem(400)`. Order: `RequireAuthorization` runs first (no validator burns CPU for unauthenticated callers), then `AddEndpointFilter`, then handler. Transient lifetime is intentional (fresh instance per request, no shared mutable state) — matches the cycle-8 batch-2 `RejectUnknownQueryParamsEndpointFilter` precedent.
|
||||
- `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` — **NEW** — unit tests for the root validator: NotEmpty (empty list), MaxBatchSize boundary (100 vs 101), per-item failure propagation with indexed paths (`items[1].latitude`).
|
||||
- `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` — **NEW** — unit tests for the per-item validator: each range rule (positive + negative), freshness window (positive + past/future negative), `FlightId` null/Guid handled. Uses local `FixedTimeProvider` (see F1 — duplicated).
|
||||
- `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` — **NEW** — 16 integration tests covering happy path + 14 failure modes (rules 2-14 + AC-4 type-mismatch). Uses `ProblemDetailsAssertions` for the RFC 7807 shape check and `AssertErrorsContainsMention` for path/message substring matching.
|
||||
- `SatelliteProvider.IntegrationTests/Program.cs` — wired `UavUploadValidationTests.RunAll` into BOTH the `smoke` and the `full` suites (matches the batch-2/3 cycle-8 pattern).
|
||||
- `scripts/probe_upload_validation.sh` — **NEW** — bash + curl probe of the happy path + every failure mode. Reuses the existing `probe_route_validation.sh` structure (JWT mint, status-code assertion, `--exit-on-fail`).
|
||||
- `_docs/02_document/contracts/api/uav-tile-upload.md` — bumped v1.1.0 → v1.2.0. Added "Metadata validation" section enumerating all 14 rules + the three enforcement layers (deserializer / FluentValidation / cross-field) + the error-shape mapping. Expanded "HTTP 400 — envelope error" section with the new failure shapes. Added v1.2.0 changelog entry.
|
||||
- `_docs/02_document/modules/api_program.md` — updated endpoint description for `POST /api/satellite/upload`; added `Api/Validators` entries for the three new files; added `Common/DTO (AZ-488)` note about the new `[JsonRequired]` attributes; added DI registration entry for `UavUploadValidationFilter`.
|
||||
|
||||
## Test Evidence
|
||||
|
||||
Pending — the `implement` skill defers the full integration-test suite run to autodev Step 11 (Run Tests). Per-file lint check on all 9 modified/new `.cs` files returned NO errors (ReadLints clean). Build sanity is implicit: ReadLints would surface compilation errors as Critical, and none surfaced.
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
- 0 Critical, 0 High, 0 Medium.
|
||||
- 2 Low findings (F1 + F2) — both DRY in test-only code, both have a clear follow-up PBI plan, both safe to defer.
|
||||
- 1 Info finding (F3) — documented design decision, contract-aligned, tests tolerant.
|
||||
- **PASS_WITH_WARNINGS** — implement skill may proceed to Step 11 (commit + ask about push). Both Low findings tracked in this report for the cumulative review (Step 14.5) and the cycle-8 implementation report.
|
||||
@@ -37,6 +37,8 @@ If the enum's wire string happens to match a member name case-insensitively (e.g
|
||||
|
||||
## Ring buffer (last 15 entries — newest at top)
|
||||
|
||||
- [2026-05-23] [process] When verifying a "no-regression" AC for an input-validation change ("AZ-NNN does not break existing tests"), the only sound evidence is a green integration-test run — tracing fixture variables back to their generators in source is insufficient because helpers can produce values outside the new bounds and previously slipped through silently when no validator existed; document the standard as "verified by reading source" → unconfirmed, "verified by full test run" → confirmed, and gate the batch report's AC table on the latter before the implement skill closes the batch (cycle 8: AZ-810 batch_04 AC-9 claimed "no AZ-488 regression" based on tracing `latitude = coord.Latitude` in test source, but `NextTestCoordinate` seeded by `(Ticks/TicksPerSecond) % 1_000_000` produced lat far above 90° at runtime; the false-PASS only surfaced at autodev Step 11 when the integration test run returned HTTP 400 from the new validator on the AZ-488 happy path).
|
||||
Source: _docs/03_implementation/batch_04_cycle8_report.md (AC-9 row)
|
||||
- [2026-05-22] [process] When the implement skill ships a cycle's batch commit without writing `_docs/03_implementation/implementation_report_*_cycle{N}.md`, downstream skills (test-spec cycle-update, document task mode, retrospective Step 1) must fall back to reading the cycle's task specs in `_docs/02_tasks/done/` plus the commit body via `git log --grep='[AZ-...]'` — codify the fallback in those skills' instructions instead of leaving it as per-cycle improvisation, because the implicit contract between Step 10 and Steps 11-17 broke silently this cycle and only succeeded because every downstream skill happened to be robust enough to substitute (cycle 7: AZ-794+AZ-795+AZ-796 shipped as commit `865dfdb` with no report artifact; doc-skill auto-walked the diff, test-spec read the task specs, retrospective wrote from the deploy + security + perf reports — all worked, but the contract was never formal).
|
||||
Source: _docs/06_metrics/retro_2026-05-22_cycle7.md
|
||||
- [2026-05-22] [testing] When a strict-validation layer ships (`JsonSerializerOptions.UnmappedMemberHandling.Disallow`, FluentValidation rules, explicit DTO `[JsonRequired]`), expect the project's own integration tests to surface latent bugs the prior lenient defaults had been masking — silent PascalCase fallback property names, out-of-range fixture coordinates, wrong-cased JSON keys; correct them in the same PR or the test suite goes red and the strict layer looks like a regression instead of the bug-finder it is (cycle 7: `IdempotentPostTests.RoutePoint` had been posting `{"Lat":...}` against a `[JsonPropertyName("lat")]` DTO for months; the new strict deserializer caught it and the 2-line payload fix landed alongside the strict layer).
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Current Step
|
||||
flow: existing-code
|
||||
step: 9
|
||||
name: New Task
|
||||
step: 11
|
||||
name: Run Tests
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 1
|
||||
name: gather-feature-description
|
||||
detail: ""
|
||||
retry_count: 0
|
||||
phase: 2
|
||||
name: run-tests
|
||||
detail: "re-run after coord-clamp fix"
|
||||
retry_count: 1
|
||||
cycle: 8
|
||||
tracker: jira
|
||||
auto_push: true
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Manual end-to-end probe for GET /api/satellite/tiles/latlon strict validation
|
||||
# (AZ-811). Each failure call should return HTTP 400 with an
|
||||
# `application/problem+json` body. The happy path should return HTTP 200.
|
||||
#
|
||||
# Two enforcement layers:
|
||||
# 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside
|
||||
# {lat, lon, zoom}.
|
||||
# 2. WithValidation<GetTileByLatLonQuery> — range-checks lat, lon, zoom.
|
||||
#
|
||||
# Usage:
|
||||
# API_URL=https://localhost:8080 JWT="<bearer-token>" ./scripts/probe_latlon_validation.sh
|
||||
|
||||
API_URL="${API_URL:-https://localhost:8080}"
|
||||
JWT="${JWT:-}"
|
||||
PATH_LATLON="${API_URL%/}/api/satellite/tiles/latlon"
|
||||
|
||||
if [[ -z "${JWT}" ]]; then
|
||||
echo "ERROR: set JWT env var to a bearer token. Mint one via:"
|
||||
echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
curl_args=(-sS -k -H "Authorization: Bearer ${JWT}" -X GET)
|
||||
|
||||
probe() {
|
||||
local label="$1"
|
||||
local query="$2"
|
||||
local expected_status="$3"
|
||||
|
||||
echo "----- ${label} (expecting HTTP ${expected_status}) -----"
|
||||
local response
|
||||
response=$(curl "${curl_args[@]}" "${PATH_LATLON}?${query}" -w "\nHTTP_STATUS=%{http_code}\n")
|
||||
echo "${response}"
|
||||
local actual_status
|
||||
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
|
||||
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||
echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: HTTP ${expected_status}"
|
||||
echo
|
||||
}
|
||||
|
||||
probe "happy-path" "lat=47.461747&lon=37.647063&zoom=18" 200
|
||||
# Validator rules — NotNull (missing required) + InclusiveBetween (range)
|
||||
probe "missing-lat" "lon=37.647063&zoom=18" 400
|
||||
probe "missing-lon" "lat=47.461747&zoom=18" 400
|
||||
probe "missing-zoom" "lat=47.461747&lon=37.647063" 400
|
||||
probe "lat-out-of-range" "lat=91&lon=37.647063&zoom=18" 400
|
||||
probe "lon-out-of-range" "lat=47.461747&lon=181&zoom=18" 400
|
||||
probe "zoom-out-of-range" "lat=47.461747&lon=37.647063&zoom=30" 400
|
||||
# Envelope rule: unknown query params (legacy pre-AZ-811 wire names + hostile probes)
|
||||
probe "legacy-param-names" "Latitude=47.461747&Longitude=37.647063&ZoomLevel=18" 400
|
||||
probe "hostile-debug-admin" "lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true" 400
|
||||
probe "typo-zooom" "lat=47.461747&lon=37.647063&zooom=18" 400
|
||||
# Type mismatch (model binder)
|
||||
probe "lat-type-mismatch" "lat=fifty&lon=37.647063&zoom=18" 400
|
||||
|
||||
echo "All probes passed."
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Manual end-to-end probe for the region-request endpoint's strict validation
|
||||
# gate (AZ-808). Each failure call should return HTTP 400 with an
|
||||
# `application/problem+json` body whose `errors` map names the offending field
|
||||
# path. The happy path should return HTTP 200.
|
||||
#
|
||||
# Usage:
|
||||
# API_URL=https://localhost:8080 JWT="<bearer-token>" ./scripts/probe_region_validation.sh
|
||||
# (defaults to https://localhost:8080 with a JWT minted via PerfBootstrap --mint-only)
|
||||
|
||||
API_URL="${API_URL:-https://localhost:8080}"
|
||||
JWT="${JWT:-}"
|
||||
PATH_REGION="${API_URL%/}/api/satellite/request"
|
||||
|
||||
if [[ -z "${JWT}" ]]; then
|
||||
echo "ERROR: set JWT env var to a bearer token. Mint one via:"
|
||||
echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
curl_args=(-sS -k -H "Authorization: Bearer ${JWT}" -H "Content-Type: application/json" -X POST "${PATH_REGION}")
|
||||
|
||||
probe() {
|
||||
local label="$1"
|
||||
local body="$2"
|
||||
local expected_status="$3"
|
||||
|
||||
echo "----- ${label} (expecting HTTP ${expected_status}) -----"
|
||||
local response
|
||||
response=$(curl "${curl_args[@]}" -d "${body}" -w "\nHTTP_STATUS=%{http_code}\n")
|
||||
echo "${response}"
|
||||
local actual_status
|
||||
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
|
||||
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||
echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: HTTP ${expected_status}"
|
||||
echo
|
||||
}
|
||||
|
||||
# Generate a unique guid for the happy path
|
||||
HAPPY_ID="$(uuidgen)"
|
||||
|
||||
probe "happy-path" "{\"id\":\"${HAPPY_ID}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 200
|
||||
# Reproduces the 2026-05-22 probe that surfaced silent-Guid-coercion (pre-AZ-808)
|
||||
probe "missing-id" '{"lat":49.94,"lon":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}' 400
|
||||
probe "zero-guid-id" '{"id":"00000000-0000-0000-0000-000000000000","lat":47.461747,"lon":37.647063,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}' 400
|
||||
probe "missing-lat" "{\"id\":\"$(uuidgen)\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "lat-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":91,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "missing-lon" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "lon-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":181,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "missing-sizeMeters" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "sizeMeters-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
probe "missing-zoomLevel" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}" 400
|
||||
probe "zoomLevel-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}" 400
|
||||
probe "missing-stitchTiles" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}" 400
|
||||
probe "lat-type-mismatch" "{\"id\":\"$(uuidgen)\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400
|
||||
# Unknown root field — confirms UnmappedMemberHandling.Disallow stays active
|
||||
probe "unknown-root-field" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false,\"unknownField\":1}" 400
|
||||
|
||||
echo "All probes passed."
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Manual end-to-end probe for POST /api/satellite/route strict validation
|
||||
# (AZ-809). Each failure call should return HTTP 400 with an
|
||||
# `application/problem+json` body. The happy path should return HTTP 200.
|
||||
#
|
||||
# Two enforcement layers:
|
||||
# 1. UnmappedMemberHandling.Disallow + [JsonRequired] — deserializer rejects
|
||||
# missing-required and unknown fields with errors via GlobalExceptionHandler.
|
||||
# 2. WithValidation<CreateRouteRequest> — runs CreateRouteRequestValidator +
|
||||
# RoutePointValidator + GeofencePolygonValidator (range, count, cross-field).
|
||||
#
|
||||
# Usage:
|
||||
# API_URL=https://localhost:8080 JWT="<bearer-token>" ./scripts/probe_route_validation.sh
|
||||
|
||||
API_URL="${API_URL:-https://localhost:8080}"
|
||||
JWT="${JWT:-}"
|
||||
ENDPOINT="${API_URL%/}/api/satellite/route"
|
||||
|
||||
if [[ -z "${JWT}" ]]; then
|
||||
echo "ERROR: set JWT env var to a bearer token. Mint one via:"
|
||||
echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
curl_args=(-sS -k -H "Authorization: Bearer ${JWT}" -H "Content-Type: application/json")
|
||||
|
||||
probe() {
|
||||
local label="$1"
|
||||
local body="$2"
|
||||
local expected_status="$3"
|
||||
|
||||
echo "----- ${label} (expecting HTTP ${expected_status}) -----"
|
||||
local response
|
||||
response=$(curl "${curl_args[@]}" -X POST -d "${body}" "${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n")
|
||||
echo "${response}"
|
||||
local actual_status
|
||||
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
|
||||
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||
echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: HTTP ${expected_status}"
|
||||
echo
|
||||
}
|
||||
|
||||
route_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
probe "happy-path-no-maps" '{
|
||||
"id": "'"${route_id}"'",
|
||||
"name": "probe-route-1",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 200
|
||||
|
||||
# Rule 2: missing id (probe-confirmed gap)
|
||||
probe "missing-id" '{
|
||||
"name": "probe-missing-id",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 2: zero-Guid id
|
||||
probe "zero-guid-id" '{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "probe-zero-id",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 3: empty name
|
||||
probe "empty-name" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 7: points too few (1)
|
||||
probe "points-too-few" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-1-point",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 8: nested point lat out of range
|
||||
probe "point-lat-out-of-range" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-point-lat",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 91.0, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 9: geofence NW not north-of SE (cross-field invariant)
|
||||
probe "geofence-nw-not-north" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-geofence-inverted",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{ "northWest": { "lat": 50.05, "lon": 36.05 },
|
||||
"southEast": { "lat": 50.05, "lon": 36.15 } }
|
||||
]
|
||||
},
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
# Rule 12: cross-field createTilesZip without requestMaps
|
||||
probe "createTilesZip-without-requestMaps" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-cross-field",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": true
|
||||
}' 400
|
||||
|
||||
# Rule 13: unknown root field
|
||||
probe "unknown-root-field" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-unknown",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false,
|
||||
"debug": "fingerprint-probe"
|
||||
}' 400
|
||||
|
||||
# Rule 14: nested type mismatch
|
||||
probe "point-lat-type-mismatch" '{
|
||||
"id": "'$(uuidgen | tr '[:upper:]' '[:lower:]')'",
|
||||
"name": "probe-type-mismatch",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": "fifty", "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}' 400
|
||||
|
||||
echo "All probes passed."
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Manual end-to-end probe for POST /api/satellite/upload strict metadata
|
||||
# validation (AZ-810). Each failure call should return HTTP 400 with an
|
||||
# `application/problem+json` body. The happy-path call should return HTTP 200
|
||||
# with the per-item result envelope.
|
||||
#
|
||||
# Three enforcement layers compose at the endpoint:
|
||||
# 1. UnmappedMemberHandling.Disallow + [JsonRequired] on the metadata DTO
|
||||
# — the UavUploadValidationFilter deserializes the `metadata` form field
|
||||
# via the strict global JsonSerializerOptions and surfaces JsonException
|
||||
# under `errors["metadata"]`. Covers missing-required, unknown fields,
|
||||
# type mismatches, malformed UUIDs (AZ-810 rules 3, 12, 13, 14).
|
||||
# 2. FluentValidation (UavTileBatchMetadataPayloadValidator +
|
||||
# UavTileMetadataValidator) — per-item range checks (lat, lon, tileZoom,
|
||||
# tileSizeMeters, capturedAt freshness) and the items.Count <=
|
||||
# MaxBatchSize cap. Errors are prefixed with `metadata.` so paths look
|
||||
# like `errors["metadata.items[0].latitude"]`. Covers AZ-810 rules 4-5,
|
||||
# 7-11.
|
||||
# 3. Cross-field envelope rule (items.Count == files.Count) — surfaces
|
||||
# under both `errors["metadata.items"]` AND `errors["files"]`. Covers
|
||||
# AZ-810 rule 6.
|
||||
#
|
||||
# Auth: the endpoint requires JWT bearer + the `permissions` claim must
|
||||
# contain `GPS` (AZ-487 / AZ-488). Mint a token via:
|
||||
# dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only
|
||||
# then jq the `permissions` claim into the GPS group, or use the GPS-specific
|
||||
# minter helper if one is exposed.
|
||||
#
|
||||
# Usage:
|
||||
# API_URL=https://localhost:8080 JWT="<bearer-token-with-GPS-permission>" \
|
||||
# ./scripts/probe_upload_validation.sh
|
||||
|
||||
API_URL="${API_URL:-https://localhost:8080}"
|
||||
JWT="${JWT:-}"
|
||||
ENDPOINT="${API_URL%/}/api/satellite/upload"
|
||||
TMPDIR="${TMPDIR:-/tmp}"
|
||||
JPEG_PATH="${TMPDIR}/probe_upload_validation.jpg"
|
||||
|
||||
if [[ -z "${JWT}" ]]; then
|
||||
echo "ERROR: set JWT env var to a bearer token whose 'permissions' claim contains 'GPS'."
|
||||
echo " Mint a default token (no GPS claim) via:"
|
||||
echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only"
|
||||
echo " Then attach the GPS permission claim manually or use a GPS-specific minter."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Emit a tiny valid JPEG (FF D8 FF D9 = empty SOI/EOI; the endpoint's
|
||||
# UavTileQualityGate Rule 1 only inspects the magic bytes, not full decode,
|
||||
# but Rules 2 / 3 / 5 will reject it. Since AZ-810 validation runs BEFORE the
|
||||
# quality gate, the validator's verdict is what we're probing here. A tiny
|
||||
# placeholder keeps multipart bodies small.)
|
||||
printf '\xff\xd8\xff\xd9' > "${JPEG_PATH}"
|
||||
|
||||
curl_args=(-sS -k -H "Authorization: Bearer ${JWT}")
|
||||
|
||||
probe() {
|
||||
local label="$1"
|
||||
local metadata="$2"
|
||||
local files_arg="$3" # quoted -F arg-list, e.g. -F 'files=@/tmp/x.jpg;type=image/jpeg'
|
||||
local expected_status="$4"
|
||||
|
||||
echo "----- ${label} (expecting HTTP ${expected_status}) -----"
|
||||
local response
|
||||
# shellcheck disable=SC2086
|
||||
response=$(curl "${curl_args[@]}" -X POST \
|
||||
-F "metadata=${metadata}" \
|
||||
${files_arg} \
|
||||
"${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n")
|
||||
echo "${response}"
|
||||
local actual_status
|
||||
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
|
||||
if [[ "${actual_status}" != "${expected_status}" ]]; then
|
||||
echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: HTTP ${expected_status}"
|
||||
echo
|
||||
}
|
||||
|
||||
# AC-2: happy path (well-formed envelope + 1 file)
|
||||
happy_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "happy-path" "${happy_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 200
|
||||
|
||||
# Rule 2: missing metadata form field
|
||||
echo "----- missing-metadata-field (expecting HTTP 400) -----"
|
||||
response=$(curl "${curl_args[@]}" -X POST \
|
||||
-F "files=@${JPEG_PATH};type=image/jpeg" \
|
||||
"${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n")
|
||||
echo "${response}"
|
||||
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
|
||||
if [[ "${actual_status}" != "400" ]]; then
|
||||
echo "FAIL: expected HTTP 400, got ${actual_status}"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: HTTP 400"
|
||||
echo
|
||||
|
||||
# Rule 3: malformed metadata JSON
|
||||
probe "malformed-json" '{"items": [{ "latitude": 50.10, "longitude": 36.10' \
|
||||
"-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 4: empty items
|
||||
probe "empty-items" '{"items": []}' "" 400
|
||||
|
||||
# Rule 6: items.Count != files.Count (2 items, 1 file)
|
||||
mismatch_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"},{"latitude":50.11,"longitude":36.11,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "items-files-mismatch" "${mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 7: lat out of range
|
||||
lat_metadata='{"items":[{"latitude":91.0,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "lat-out-of-range" "${lat_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 8: lon out of range
|
||||
lon_metadata='{"items":[{"latitude":50.10,"longitude":181.0,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "lon-out-of-range" "${lon_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 9: tileZoom out of range
|
||||
zoom_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":30,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "tileZoom-out-of-range" "${zoom_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 10: tileSizeMeters non-positive
|
||||
size_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":0.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "tileSizeMeters-non-positive" "${size_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 11a: capturedAt in the future (use a date 1 year out for portability)
|
||||
future_iso="$(date -u -v+1y +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '+1 year' +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
future_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${future_iso}"'"}]}'
|
||||
probe "capturedAt-future" "${future_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 11b: capturedAt too old (60 days)
|
||||
old_iso="$(date -u -v-60d +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '60 days ago' +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
old_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${old_iso}"'"}]}'
|
||||
probe "capturedAt-too-old" "${old_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 12: malformed flightId UUID
|
||||
flight_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","flightId":"not-a-uuid"}]}'
|
||||
probe "flightId-malformed" "${flight_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 13: unknown root field
|
||||
unknown_root_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}],"debug":"fingerprint"}'
|
||||
probe "unknown-root-field" "${unknown_root_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 13b: unknown nested field
|
||||
unknown_nested_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","altitude":500.0}]}'
|
||||
probe "unknown-nested-field" "${unknown_nested_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
# Rule 14: type mismatch (latitude as string)
|
||||
type_mismatch_metadata='{"items":[{"latitude":"fifty","longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
|
||||
probe "lat-type-mismatch" "${type_mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
|
||||
|
||||
echo "All probes passed."
|
||||
@@ -169,7 +169,7 @@ echo "PT-01: Tile Download Latency (cold) (threshold: 30000ms)"
|
||||
PT01_LAT="47.461347"
|
||||
PT01_LON="37.646663"
|
||||
START=$(date +%s%N)
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=$PT01_LAT&Longitude=$PT01_LON&ZoomLevel=18")
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?lat=$PT01_LAT&lon=$PT01_LON&zoom=18")
|
||||
END=$(date +%s%N)
|
||||
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
||||
if [[ "$HTTP_CODE" == "200" ]]; then
|
||||
@@ -182,7 +182,7 @@ fi
|
||||
echo ""
|
||||
echo "PT-02: Cached Tile Retrieval Latency (threshold: 500ms)"
|
||||
START=$(date +%s%N)
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18")
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18")
|
||||
END=$(date +%s%N)
|
||||
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
||||
|
||||
@@ -197,7 +197,7 @@ fi
|
||||
echo ""
|
||||
echo "PT-03: Region Processing 200m / zoom 18 (threshold: 60000ms)"
|
||||
PT03_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
PT03_BODY="{\"id\":\"$PT03_ID\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
PT03_BODY="{\"id\":\"$PT03_ID\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
START=$(date +%s%N)
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$PT03_BODY" "$API_URL/api/satellite/request")
|
||||
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "202" ]]; then
|
||||
@@ -218,7 +218,7 @@ fi
|
||||
echo ""
|
||||
echo "PT-04: Region Processing 500m / zoom 18 + stitch (threshold: 120000ms)"
|
||||
PT04_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
PT04_BODY="{\"id\":\"$PT04_ID\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":500,\"zoomLevel\":18,\"stitchTiles\":true}"
|
||||
PT04_BODY="{\"id\":\"$PT04_ID\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":500,\"zoomLevel\":18,\"stitchTiles\":true}"
|
||||
START=$(date +%s%N)
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$PT04_BODY" "$API_URL/api/satellite/request")
|
||||
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "202" ]]; then
|
||||
@@ -245,7 +245,7 @@ for i in 1 2 3 4 5; do
|
||||
PT05_IDS+=("$rid")
|
||||
LAT=$(awk "BEGIN { printf \"%.6f\", 47.461747 + 0.001 * $i }")
|
||||
LON=$(awk "BEGIN { printf \"%.6f\", 37.647063 + 0.001 * $i }")
|
||||
BODY="{\"id\":\"$rid\",\"latitude\":$LAT,\"longitude\":$LON,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
BODY="{\"id\":\"$rid\",\"lat\":$LAT,\"lon\":$LON,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$BODY" "$API_URL/api/satellite/request")
|
||||
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "202" ]]; then
|
||||
echo " ✗ PT-05: enqueue $i HTTP $HTTP_CODE (expected 200/202)"
|
||||
@@ -303,7 +303,7 @@ for ((i=0; i<PERF_REPEAT_COUNT; i++)); do
|
||||
rid=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
lat=$(awk -v base="$PT07_BASE_LAT" -v idx="$i" 'BEGIN { printf "%.6f", base + 0.002 * idx }')
|
||||
lon=$(awk -v base="$PT07_BASE_LON" -v idx="$i" 'BEGIN { printf "%.6f", base + 0.002 * idx }')
|
||||
body="{\"id\":\"$rid\",\"latitude\":$lat,\"longitude\":$lon,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
body="{\"id\":\"$rid\",\"lat\":$lat,\"lon\":$lon,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
start=$(date +%s%N)
|
||||
code=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$body" "$API_URL/api/satellite/request")
|
||||
if [[ "$code" != "200" && "$code" != "202" ]]; then
|
||||
@@ -327,7 +327,7 @@ for ((i=0; i<PERF_REPEAT_COUNT; i++)); do
|
||||
rid=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
lat=$(awk -v base="$PT07_BASE_LAT" -v idx="$i" 'BEGIN { printf "%.6f", base + 0.002 * idx }')
|
||||
lon=$(awk -v base="$PT07_BASE_LON" -v idx="$i" 'BEGIN { printf "%.6f", base + 0.002 * idx }')
|
||||
body="{\"id\":\"$rid\",\"latitude\":$lat,\"longitude\":$lon,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
body="{\"id\":\"$rid\",\"lat\":$lat,\"lon\":$lon,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"
|
||||
start=$(date +%s%N)
|
||||
code=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$body" "$API_URL/api/satellite/request")
|
||||
if [[ "$code" != "200" && "$code" != "202" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user