using FluentAssertions; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Services.TileDownloader; using SatelliteProvider.Tests.TestUtilities; namespace SatelliteProvider.Tests; public class UavTileQualityGateTests { private const string JpegContentType = "image/jpeg"; [Fact] public void Validate_NonJpegContentType_RejectsInvalidFormat() { // Arrange var gate = BuildGate(); var bytes = UavTileImageFactory.CreatePng(); var metadata = ValidMetadata(); // Act var result = gate.Validate(bytes, "image/png", metadata); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.InvalidFormat); } [Fact] public void Validate_WrongMagicBytes_RejectsInvalidFormat() { // Arrange var gate = BuildGate(); var bytes = new byte[6_000]; bytes[0] = 0x89; bytes[1] = 0x50; bytes[2] = 0x4E; bytes[3] = 0x47; var metadata = ValidMetadata(); // Act var result = gate.Validate(bytes, JpegContentType, metadata); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.InvalidFormat); } [Fact] public void Validate_ValidJpeg_PassesFormatRule() { // Arrange var gate = BuildGate(); var bytes = UavTileImageFactory.CreateRandomJpeg(); var metadata = ValidMetadata(); // Act var result = gate.Validate(bytes, JpegContentType, metadata); // Assert result.Accepted.Should().BeTrue(); } [Fact] public void Validate_TooSmall_RejectsSizeOutOfBand() { // Arrange var bytes = new byte[200]; bytes[0] = 0xFF; bytes[1] = 0xD8; bytes[2] = 0xFF; var gate = BuildGate(); // Act var result = gate.Validate(bytes, JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.SizeOutOfBand); } [Fact] public void Validate_TooLarge_RejectsSizeOutOfBand() { // Arrange — craft a JPEG-prefixed byte blob just over the configured MaxBytes ceiling. var qualityConfig = new UavQualityConfig { MaxBytes = 8 * 1024 }; var oversized = new byte[qualityConfig.MaxBytes + 1]; oversized[0] = 0xFF; oversized[1] = 0xD8; oversized[2] = 0xFF; var gate = BuildGate(qualityConfig); // Act var result = gate.Validate(oversized, JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.SizeOutOfBand); } [Fact] public void Validate_WrongDimensions_RejectsWrongDimensions() { // Arrange var bytes = UavTileImageFactory.CreateRandomJpeg(width: 512, height: 512); var gate = BuildGate(); // Act var result = gate.Validate(bytes, JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.WrongDimensions); } [Fact] public void Validate_CapturedAtFuture_RejectsCapturedAtFuture() { // Arrange var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); var metadata = ValidMetadata() with { CapturedAt = now.AddHours(1) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.CapturedAtFuture); } [Fact] public void Validate_CapturedAtTooOld_RejectsCapturedAtTooOld() { // Arrange var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); var metadata = ValidMetadata() with { CapturedAt = now.AddDays(-8) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.CapturedAtTooOld); } [Fact] public void Validate_CapturedAtSlightlyInFuture_WithinSkew_Accepts() { // Arrange var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); var metadata = ValidMetadata() with { CapturedAt = now.AddSeconds(20) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); // Assert result.Accepted.Should().BeTrue(); } [Fact] public void Validate_UniformGreyImage_RejectsImageTooUniform() { // Arrange — uniform-color JPEGs compress to far below the // default 5 KiB MinBytes (rule 2). Drop MinBytes so rule 5 // remains the active filter for this test. var bytes = UavTileImageFactory.CreateUniformGreyJpeg(value: 128); var gate = BuildGate(new UavQualityConfig { MinBytes = 1 }); // Act var result = gate.Validate(bytes, JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.ImageTooUniform); } [Fact] public void Validate_FirstFailingRuleWins_FormatBeforeDimensions() { // Arrange — a PNG resized to 512x512 fails BOTH rule 1 (content-type // says JPEG but magic bytes don't) AND rule 3 (dimensions != 256). // Rule 1 runs first so we expect INVALID_FORMAT. var bytes = UavTileImageFactory.CreatePng(width: 512, height: 512); var gate = BuildGate(); // Act var result = gate.Validate(bytes, JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeFalse(); result.Reason.Should().Be(UavTileRejectReasons.InvalidFormat); } [Fact] public void Validate_HappyPath_RandomNoiseJpeg_Accepts() { // Act var result = BuildGate().Validate(UavTileImageFactory.CreateRandomJpeg(), JpegContentType, ValidMetadata()); // Assert result.Accepted.Should().BeTrue(); result.Reason.Should().BeNull(); } private static UavTileQualityGate BuildGate(UavQualityConfig? quality = null, MapConfig? map = null, TimeProvider? timeProvider = null) { return new UavTileQualityGate( Options.Create(quality ?? new UavQualityConfig()), Options.Create(map ?? new MapConfig { TileSizePixels = 256 }), timeProvider ?? new FixedTimeProvider(new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc))); } private static UavTileMetadata ValidMetadata() => new() { Latitude = 47.461747, Longitude = 37.647063, TileZoom = 18, TileSizeMeters = 200.0, CapturedAt = new DateTime(2026, 5, 11, 11, 30, 0, DateTimeKind.Utc), }; private sealed class FixedTimeProvider : TimeProvider { private readonly DateTime _utcNow; public FixedTimeProvider(DateTime utcNow) { if (utcNow.Kind != DateTimeKind.Utc) { throw new ArgumentException("DateTime must be UTC", nameof(utcNow)); } _utcNow = utcNow; } public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero); } }