mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-27 09:01:13 +00:00
[AZ-1126] Migrate capturedAt to DateTimeOffset
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -54,9 +54,9 @@ public sealed class UavTileMetadataValidator : AbstractValidator<UavTileMetadata
|
|||||||
// time per call (rule executes at validation time, not constructor
|
// time per call (rule executes at validation time, not constructor
|
||||||
// time). Equivalent to AZ-488 Rule 4 in UavTileQualityGate.
|
// time). Equivalent to AZ-488 Rule 4 in UavTileQualityGate.
|
||||||
RuleFor(m => m.CapturedAt)
|
RuleFor(m => m.CapturedAt)
|
||||||
.Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
|
.Must(capturedAt => capturedAt.UtcDateTime <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
|
||||||
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
|
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
|
||||||
.Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
|
.Must(capturedAt => capturedAt.UtcDateTime >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
|
||||||
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
|
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
|
||||||
|
|
||||||
// `FlightId` is intentionally not validated beyond JSON shape — AZ-503
|
// `FlightId` is intentionally not validated beyond JSON shape — AZ-503
|
||||||
|
|||||||
@@ -73,11 +73,12 @@ public sealed class UavUploadValidationFilter : IEndpointFilter
|
|||||||
{
|
{
|
||||||
payload = JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(metadataField, _jsonOptions);
|
payload = JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(metadataField, _jsonOptions);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
|
var message = string.IsNullOrWhiteSpace(ex.Message) ? MetadataJsonParseError : ex.Message;
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
{
|
{
|
||||||
[MetadataField] = new[] { MetadataJsonParseError },
|
[MetadataField] = new[] { message },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using SatelliteProvider.Common.Json;
|
||||||
|
|
||||||
namespace SatelliteProvider.Common.DTO;
|
namespace SatelliteProvider.Common.DTO;
|
||||||
|
|
||||||
// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each
|
// AZ-488 / `uav-tile-upload.md` — per-tile metadata supplied with each batch
|
||||||
// batch item. `CapturedAt` is normalized to UTC by the upload handler before
|
// item. `CapturedAt` is UTC-aware (`DateTimeOffset`) and normalized to UTC
|
||||||
// reaching the persistence layer.
|
// before persistence (AZ-1126 / F-AZ810-2).
|
||||||
//
|
//
|
||||||
// AZ-503: `FlightId` is optional. When provided, two UAVs uploading the same
|
// AZ-503: `FlightId` is optional. When provided, two UAVs uploading the same
|
||||||
// (z, x, y) cell from different flights coexist as distinct DB rows and write
|
// (z, x, y) cell from different flights coexist as distinct DB rows and write
|
||||||
@@ -27,7 +28,8 @@ public record UavTileMetadata
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public double TileSizeMeters { get; init; }
|
public double TileSizeMeters { get; init; }
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTime CapturedAt { get; init; }
|
[JsonConverter(typeof(UtcOffsetRequiredDateTimeOffsetConverter))]
|
||||||
|
public DateTimeOffset CapturedAt { get; init; }
|
||||||
public Guid? FlightId { get; init; }
|
public Guid? FlightId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SatelliteProvider.Common.Json;
|
||||||
|
|
||||||
|
public sealed partial class UtcOffsetRequiredDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ExplicitOffsetPattern();
|
||||||
|
|
||||||
|
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType != JsonTokenType.String)
|
||||||
|
{
|
||||||
|
throw new JsonException("`capturedAt` must be an ISO-8601 string with an explicit UTC offset.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = reader.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
throw new JsonException("`capturedAt` must be an ISO-8601 string with an explicit UTC offset.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ExplicitOffsetPattern().IsMatch(raw))
|
||||||
|
{
|
||||||
|
throw new JsonException("`capturedAt` must include an explicit UTC offset (for example `Z` or `+00:00`).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
|
||||||
|
{
|
||||||
|
throw new JsonException("`capturedAt` could not be parsed as an ISO-8601 timestamp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteStringValue(value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@ public static class UavUploadValidationTests
|
|||||||
// Rule 14: type mismatch (lat as string)
|
// Rule 14: type mismatch (lat as string)
|
||||||
await ItemLatTypeMismatch_Returns400(apiUrl, secret);
|
await ItemLatTypeMismatch_Returns400(apiUrl, secret);
|
||||||
|
|
||||||
|
// AZ-1126: offset-less capturedAt rejected at deserializer
|
||||||
|
await ItemCapturedAtOffsetLess_Returns400(apiUrl, secret);
|
||||||
|
|
||||||
Console.WriteLine("✓ UAV upload metadata validation tests: PASSED");
|
Console.WriteLine("✓ UAV upload metadata validation tests: PASSED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +432,33 @@ public static class UavUploadValidationTests
|
|||||||
Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path");
|
Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task ItemCapturedAtOffsetLess_Returns400(string apiUrl, string secret)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("AZ-1126 AC-2: per-item capturedAt without explicit UTC offset → HTTP 400");
|
||||||
|
|
||||||
|
// Arrange
|
||||||
|
var coord = NextTestCoordinate();
|
||||||
|
var metadata = new
|
||||||
|
{
|
||||||
|
items = new[]
|
||||||
|
{
|
||||||
|
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = "2026-06-26T12:00:00" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||||
|
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-1126 item capturedAt offset-less");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-1126 item capturedAt offset-less");
|
||||||
|
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "capturedAt", label: "AZ-1126 item capturedAt offset-less");
|
||||||
|
|
||||||
|
Console.WriteLine(" ✓ items[0].capturedAt without offset rejected");
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret)
|
private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
|||||||
@@ -80,9 +80,7 @@ public sealed class UavTileQualityGate : IUavTileQualityGate
|
|||||||
// Rule 4 (Captured-at age): forbid future timestamps beyond clock skew and
|
// Rule 4 (Captured-at age): forbid future timestamps beyond clock skew and
|
||||||
// reject anything older than the configured max age.
|
// reject anything older than the configured max age.
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var capturedAt = metadata.CapturedAt.Kind == DateTimeKind.Utc
|
var capturedAt = metadata.CapturedAt.UtcDateTime;
|
||||||
? metadata.CapturedAt
|
|
||||||
: metadata.CapturedAt.ToUniversalTime();
|
|
||||||
|
|
||||||
if (capturedAt > now.AddSeconds(_qualityConfig.CapturedAtFutureSkewSeconds))
|
if (capturedAt > now.AddSeconds(_qualityConfig.CapturedAtFutureSkewSeconds))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -157,9 +157,7 @@ public sealed class UavTileUploadHandler : IUavTileUploadHandler
|
|||||||
var imageArray = imageBytes.ToArray();
|
var imageArray = imageBytes.ToArray();
|
||||||
await File.WriteAllBytesAsync(filePath, imageArray, cancellationToken);
|
await File.WriteAllBytesAsync(filePath, imageArray, cancellationToken);
|
||||||
|
|
||||||
var capturedAtUtc = metadata.CapturedAt.Kind == DateTimeKind.Utc
|
var capturedAtUtc = metadata.CapturedAt.UtcDateTime;
|
||||||
? metadata.CapturedAt
|
|
||||||
: metadata.CapturedAt.ToUniversalTime();
|
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
// AZ-503: deterministic id from (z, x, y, source, flight_id-or-zero) and
|
// AZ-503: deterministic id from (z, x, y, source, flight_id-or-zero) and
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SatelliteProvider.Common.DTO;
|
||||||
|
using SatelliteProvider.Common.Json;
|
||||||
|
|
||||||
|
namespace SatelliteProvider.Tests.Json;
|
||||||
|
|
||||||
|
public class UtcOffsetRequiredDateTimeOffsetConverterTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new UtcOffsetRequiredDateTimeOffsetConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"2026-06-26T12:00:00Z\"")]
|
||||||
|
[InlineData("\"2026-06-26T12:00:00+00:00\"")]
|
||||||
|
[InlineData("\"2026-06-26T12:00:00.1234567Z\"")]
|
||||||
|
public void Deserialize_ExplicitUtcOffset_Parses(string capturedAtJson)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var json = $$"""{"latitude":50.1,"longitude":36.1,"tileZoom":18,"tileSizeMeters":200,"capturedAt":{{capturedAtJson}}}""";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var metadata = JsonSerializer.Deserialize<UavTileMetadata>(json, Options);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
metadata.Should().NotBeNull();
|
||||||
|
metadata!.CapturedAt.Offset.Should().Be(TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deserialize_OffsetLessIsoString_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string json = """{"latitude":50.1,"longitude":36.1,"tileZoom":18,"tileSizeMeters":200,"capturedAt":"2026-06-26T12:00:00"}""";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => JsonSerializer.Deserialize<UavTileMetadata>(json, Options);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
act.Should().Throw<JsonException>().WithMessage("*explicit UTC offset*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ public class UavTileQualityGateTests
|
|||||||
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
||||||
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
||||||
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
||||||
var metadata = ValidMetadata() with { CapturedAt = now.AddHours(1) };
|
var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddHours(1), TimeSpan.Zero) };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = gate.Validate(bytes, JpegContentType, metadata);
|
var result = gate.Validate(bytes, JpegContentType, metadata);
|
||||||
@@ -131,7 +131,7 @@ public class UavTileQualityGateTests
|
|||||||
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
||||||
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
||||||
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
||||||
var metadata = ValidMetadata() with { CapturedAt = now.AddDays(-8) };
|
var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddDays(-8), TimeSpan.Zero) };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = gate.Validate(bytes, JpegContentType, metadata);
|
var result = gate.Validate(bytes, JpegContentType, metadata);
|
||||||
@@ -148,7 +148,7 @@ public class UavTileQualityGateTests
|
|||||||
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc);
|
||||||
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
var gate = BuildGate(timeProvider: new FixedTimeProvider(now));
|
||||||
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
var bytes = UavTileImageFactory.CreateRandomJpeg();
|
||||||
var metadata = ValidMetadata() with { CapturedAt = now.AddSeconds(20) };
|
var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddSeconds(20), TimeSpan.Zero) };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = gate.Validate(bytes, JpegContentType, metadata);
|
var result = gate.Validate(bytes, JpegContentType, metadata);
|
||||||
@@ -216,7 +216,7 @@ public class UavTileQualityGateTests
|
|||||||
Longitude = 37.647063,
|
Longitude = 37.647063,
|
||||||
TileZoom = 18,
|
TileZoom = 18,
|
||||||
TileSizeMeters = 200.0,
|
TileSizeMeters = 200.0,
|
||||||
CapturedAt = new DateTime(2026, 5, 11, 11, 30, 0, DateTimeKind.Utc),
|
CapturedAt = new DateTimeOffset(2026, 5, 11, 11, 30, 0, TimeSpan.Zero),
|
||||||
};
|
};
|
||||||
|
|
||||||
private sealed class FixedTimeProvider : TimeProvider
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ public class UavTileUploadHandlerTests : IDisposable
|
|||||||
Longitude = 37.647063,
|
Longitude = 37.647063,
|
||||||
TileZoom = 18,
|
TileZoom = 18,
|
||||||
TileSizeMeters = 200.0,
|
TileSizeMeters = 200.0,
|
||||||
CapturedAt = new DateTime(2026, 5, 11, 11, 30, 0, DateTimeKind.Utc),
|
CapturedAt = new DateTimeOffset(2026, 5, 11, 11, 30, 0, TimeSpan.Zero),
|
||||||
};
|
};
|
||||||
|
|
||||||
private sealed class FixedTimeProvider : TimeProvider
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace SatelliteProvider.Tests.Validators;
|
|||||||
public class UavTileBatchMetadataPayloadValidatorTests
|
public class UavTileBatchMetadataPayloadValidatorTests
|
||||||
{
|
{
|
||||||
private readonly UavTileBatchMetadataPayloadValidator _validator;
|
private readonly UavTileBatchMetadataPayloadValidator _validator;
|
||||||
private readonly DateTime _now;
|
private readonly DateTimeOffset _now;
|
||||||
|
|
||||||
public UavTileBatchMetadataPayloadValidatorTests()
|
public UavTileBatchMetadataPayloadValidatorTests()
|
||||||
{
|
{
|
||||||
@@ -22,7 +22,7 @@ public class UavTileBatchMetadataPayloadValidatorTests
|
|||||||
MaxAgeDays = 7,
|
MaxAgeDays = 7,
|
||||||
CapturedAtFutureSkewSeconds = 30,
|
CapturedAtFutureSkewSeconds = 30,
|
||||||
});
|
});
|
||||||
_now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc);
|
_now = new DateTimeOffset(2026, 5, 22, 12, 0, 0, TimeSpan.Zero);
|
||||||
_validator = new UavTileBatchMetadataPayloadValidator(config, new FixedTimeProvider(_now));
|
_validator = new UavTileBatchMetadataPayloadValidator(config, new FixedTimeProvider(_now));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ public class UavTileBatchMetadataPayloadValidatorTests
|
|||||||
|
|
||||||
private sealed class FixedTimeProvider : TimeProvider
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
private readonly DateTime _utcNow;
|
private readonly DateTimeOffset _utcNow;
|
||||||
public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow;
|
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||||
public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero);
|
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace SatelliteProvider.Tests.Validators;
|
|||||||
public class UavTileMetadataValidatorTests
|
public class UavTileMetadataValidatorTests
|
||||||
{
|
{
|
||||||
private readonly UavTileMetadataValidator _validator;
|
private readonly UavTileMetadataValidator _validator;
|
||||||
private readonly DateTime _now;
|
private readonly DateTimeOffset _now;
|
||||||
|
|
||||||
public UavTileMetadataValidatorTests()
|
public UavTileMetadataValidatorTests()
|
||||||
{
|
{
|
||||||
@@ -23,7 +23,7 @@ public class UavTileMetadataValidatorTests
|
|||||||
MaxAgeDays = 7,
|
MaxAgeDays = 7,
|
||||||
CapturedAtFutureSkewSeconds = 30,
|
CapturedAtFutureSkewSeconds = 30,
|
||||||
});
|
});
|
||||||
_now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc);
|
_now = new DateTimeOffset(2026, 5, 22, 12, 0, 0, TimeSpan.Zero);
|
||||||
_validator = new UavTileMetadataValidator(config, new FixedTimeProvider(_now));
|
_validator = new UavTileMetadataValidator(config, new FixedTimeProvider(_now));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ public class UavTileMetadataValidatorTests
|
|||||||
// consumer appears, promote to SatelliteProvider.TestSupport.
|
// consumer appears, promote to SatelliteProvider.TestSupport.
|
||||||
private sealed class FixedTimeProvider : TimeProvider
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
private readonly DateTime _utcNow;
|
private readonly DateTimeOffset _utcNow;
|
||||||
public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow;
|
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||||
public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero);
|
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UavTileMetadata ValidMetadata(DateTime capturedAt) => new()
|
private static UavTileMetadata ValidMetadata(DateTimeOffset capturedAt) => new()
|
||||||
{
|
{
|
||||||
Latitude = 50.10,
|
Latitude = 50.10,
|
||||||
Longitude = 36.10,
|
Longitude = 36.10,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
**Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md`
|
**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`)
|
**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
|
**Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client
|
||||||
**Version**: 1.2.0
|
**Version**: 1.2.1
|
||||||
**Status**: frozen
|
**Status**: frozen
|
||||||
**Last Updated**: 2026-05-23
|
**Last Updated**: 2026-05-23
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ Multipart form fields (case-sensitive part names):
|
|||||||
| `longitude` | number | yes | Geographic longitude of the tile center | WGS-84 decimal degrees |
|
| `longitude` | number | yes | Geographic longitude of the tile center | WGS-84 decimal degrees |
|
||||||
| `tileZoom` | integer | yes | Slippy Map zoom level | Must satisfy the same zoom-level policy as the existing tile pipeline (see `MapConfig.AllowedZoomLevels`) |
|
| `tileZoom` | integer | yes | Slippy Map zoom level | Must satisfy the same zoom-level policy as the existing tile pipeline (see `MapConfig.AllowedZoomLevels`) |
|
||||||
| `tileSizeMeters` | number | yes | Tile size in meters at the captured latitude | Producer-supplied |
|
| `tileSizeMeters` | number | yes | Tile size in meters at the captured latitude | Producer-supplied |
|
||||||
| `capturedAt` | string (ISO-8601 UTC) | yes | Moment of UAV image capture | Must satisfy the captured-at rule (see Quality Gate, Rule 4) |
|
| `capturedAt` | string (ISO-8601 with explicit UTC offset) | yes | Moment of UAV image capture | Must include an explicit offset (`Z` or `+00:00`); offset-less timestamps are rejected. Must satisfy the captured-at rule (see Quality Gate, Rule 4) |
|
||||||
| `flightId` | string (UUID) | no | AZ-503: optional flight identifier. When present, two flights uploading the same cell coexist as separate rows; absent uploads share a single anonymous row per cell. Omitting the field is fully backward-compatible with v1.0.0 clients. | RFC 4122 UUID. Backward-compatible default: `null` |
|
| `flightId` | string (UUID) | no | AZ-503: optional flight identifier. When present, two flights uploading the same cell coexist as separate rows; absent uploads share a single anonymous row per cell. Omitting the field is fully backward-compatible with v1.0.0 clients. | RFC 4122 UUID. Backward-compatible default: `null` |
|
||||||
|
|
||||||
Field names are camelCase. Property-name matching is case-insensitive on read.
|
Field names are camelCase. Property-name matching is case-insensitive on read.
|
||||||
@@ -238,3 +238,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.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.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) |
|
| 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) |
|
||||||
|
| 1.2.1 | 2026-06-26 | Patch for AZ-1126 / F-AZ810-2: `capturedAt` is typed as `DateTimeOffset` at the metadata DTO layer. Offset-less ISO-8601 strings (e.g. `"2026-06-26T12:00:00"` without `Z` or `+00:00`) are rejected at deserialization with HTTP 400. Compliant clients already sending `Z`-suffixed timestamps are unchanged. Closes security finding F-AZ810-2. | autodev (cycle 13) |
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ Step 9 cycle 13: 1 task created (AZ-1126 = 2 pts) — `DateTime` → `DateTimeOf
|
|||||||
|
|
||||||
| Task | Depends On | Points | Status |
|
| Task | Depends On | Points | Status |
|
||||||
|------|-----------|--------|--------|
|
|------|-----------|--------|--------|
|
||||||
| AZ-1126 capturedAt DateTimeOffset (F-AZ810-2) | AZ-810, AZ-488 | 2 | Todo |
|
| AZ-1126 capturedAt DateTimeOffset (F-AZ810-2) | AZ-810, AZ-488 | 2 | Done (In Testing) |
|
||||||
|
|
||||||
## Coverage Verification
|
## Coverage Verification
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Batch Report
|
||||||
|
|
||||||
|
**Batch**: 1
|
||||||
|
**Tasks**: AZ-1126_captured_at_datetimeoffset
|
||||||
|
**Date**: 2026-06-26
|
||||||
|
**Cycle**: 13
|
||||||
|
|
||||||
|
## Task Results
|
||||||
|
|
||||||
|
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||||
|
|------|--------|---------------|-------|-------------|--------|
|
||||||
|
| AZ-1126 | Done | 12 files | pass | 4/4 ACs covered | None |
|
||||||
|
|
||||||
|
## AC Test Coverage: All covered
|
||||||
|
|
||||||
|
| AC | Evidence |
|
||||||
|
|----|----------|
|
||||||
|
| AC-1 | `UavTileMetadataValidatorTests`, `UavTileQualityGateTests`, `UavTileUploadHandlerTests` |
|
||||||
|
| AC-2 | `UtcOffsetRequiredDateTimeOffsetConverterTests`, `UavUploadValidationTests.ItemCapturedAtOffsetLess_Returns400` |
|
||||||
|
| AC-3 | `UavUploadValidationTests.HappyPath_Returns200`, existing `UavUploadTests` suite |
|
||||||
|
| AC-4 | `uav-tile-upload.md` v1.2.1 change log |
|
||||||
|
|
||||||
|
## Code Review Verdict: PASS_WITH_WARNINGS
|
||||||
|
|
||||||
|
Self-review: type migration is localized; filter now surfaces `JsonException.Message` for metadata parse failures (improves flightId and capturedAt diagnostics). No architecture drift.
|
||||||
|
|
||||||
|
## Auto-Fix Attempts: 1
|
||||||
|
|
||||||
|
Integration test initially failed because `UavUploadValidationFilter` replaced all `JsonException` messages with a generic parse error; fixed by propagating the converter message.
|
||||||
|
|
||||||
|
## Stuck Agents: None
|
||||||
|
|
||||||
|
## Full Test Run
|
||||||
|
|
||||||
|
`./scripts/run-tests.sh` (mode=full) — all unit + integration tests passed after filter fix.
|
||||||
|
|
||||||
|
## Next Batch: All tasks complete
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Product Implementation Completeness — Cycle 13
|
||||||
|
|
||||||
|
**Date**: 2026-06-26
|
||||||
|
**Cycle**: 13
|
||||||
|
|
||||||
|
## Per-Task Classification
|
||||||
|
|
||||||
|
| Task | Verdict | Evidence |
|
||||||
|
|------|---------|----------|
|
||||||
|
| AZ-1126 | PASS | `UavTileMetadata.CapturedAt` is `DateTimeOffset` with `UtcOffsetRequiredDateTimeOffsetConverter`; validator, quality gate, and upload handler compare via `UtcDateTime`; contract patched to 1.2.1; integration + unit tests green |
|
||||||
|
|
||||||
|
## System Pipeline Audit
|
||||||
|
|
||||||
|
No new end-to-end pipelines introduced. UAV upload pipeline remains WIRED (filter → handler → quality gate → repository).
|
||||||
|
|
||||||
|
## Gate Verdict: PASS
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Implementation Report — capturedAt DateTimeOffset (Cycle 13)
|
||||||
|
|
||||||
|
**Cycle**: 13
|
||||||
|
**Tasks**: AZ-1126
|
||||||
|
**Date**: 2026-06-26
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Migrated `UavTileMetadata.CapturedAt` from `DateTime` to `DateTimeOffset` with a strict JSON converter requiring explicit UTC offsets. Closes security finding F-AZ810-2.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- `UtcOffsetRequiredDateTimeOffsetConverter` rejects offset-less ISO-8601 strings at deserialization
|
||||||
|
- FluentValidation freshness rules and quality gate use `UtcDateTime` without `DateTimeKind` branching
|
||||||
|
- `UavUploadValidationFilter` propagates `JsonException.Message` for metadata parse failures
|
||||||
|
- Contract `uav-tile-upload.md` 1.2.0 → 1.2.1
|
||||||
|
|
||||||
|
## Test Evidence
|
||||||
|
|
||||||
|
- Unit: `UtcOffsetRequiredDateTimeOffsetConverterTests`, updated UAV validator/gate/handler tests
|
||||||
|
- Integration: `UavUploadValidationTests.ItemCapturedAtOffsetLess_Returns400`
|
||||||
|
- Full suite: `./scripts/run-tests.sh` passed (mode=full)
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
Full test suite already executed in Step 10; Step 11 (Run Tests) may treat this as pre-verified per implement skill Step 16 handoff.
|
||||||
+12
-10
@@ -2,21 +2,23 @@
|
|||||||
|
|
||||||
## Current Step
|
## Current Step
|
||||||
flow: existing-code
|
flow: existing-code
|
||||||
step: 10
|
step: 11
|
||||||
name: Implement
|
name: Run Tests
|
||||||
status: in_progress
|
status: not_started
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 1
|
phase: 0
|
||||||
name: parse
|
name: awaiting-invocation
|
||||||
detail: ""
|
detail: ""
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 12
|
cycle: 13
|
||||||
tracker: jira
|
tracker: jira
|
||||||
auto_push: true
|
auto_push: true
|
||||||
|
|
||||||
## Last Completed Cycle
|
## Last Completed Cycle
|
||||||
cycle: 11
|
cycle: 12
|
||||||
step_16_deploy: completed
|
step_14_security: skipped
|
||||||
step_16_5_release: skipped (no release harness)
|
step_15_perf: completed
|
||||||
|
step_16_deploy: skipped
|
||||||
|
step_16_5_release: skipped
|
||||||
step_17_retrospective: completed
|
step_17_retrospective: completed
|
||||||
verdict: cycle_complete_operator_deploy
|
verdict: cycle_complete
|
||||||
|
|||||||
Reference in New Issue
Block a user