Files
satellite-provider/SatelliteProvider.Tests/UavTileUploadHandlerTests.cs
T
Oleksandr Bezdieniezhnykh 1802d32107 [AZ-488] UAV tile batch upload + 5-rule quality gate
Replaces the 501 stub at POST /api/satellite/upload with a multipart
batch endpoint that ingests UAV-captured tiles, runs each item through
a 5-rule quality gate, and persists accepted tiles via the AZ-484
multi-source storage path with source='uav'.

Quality gate (in fixed order, first failure wins): JPEG format
(content-type + magic), size band 5 KiB-5 MiB, exact 256x256
dimensions, captured-at age (no future >30 s skew, no older than
7 days), luminance variance on 32x32 downsample. Closed reject-reason
enumeration in v1.0.0 contract.

Authorization: custom PermissionsRequirement / PermissionsAuthorization
Handler that reads the JWT `permissions` claim (tolerates both
repeated-string and JSON-array shapes). Endpoint protected by
RequiresGpsPermission policy; 401 without token, 403 without GPS perm.

Persistence: file-first to ./tiles/uav/{z}/{x}/{y}.jpg, then
ITileRepository.InsertAsync UPSERT (per-source UPSERT contract from
AZ-484). Per-item failures reported in response without aborting the
batch. Kestrel MaxRequestBodySize and FormOptions limits set to
MaxBatchSize x MaxBytes (default 100 x 5 MiB = 500 MiB).

New frozen contract: _docs/02_document/contracts/api/uav-tile-upload.md
v1.0.0. PT-08 NFR added to performance-tests.md as Deferred (harness
work tracked in PT-07 leftover, per AZ-488 § Risk 4).

Tests: 11 quality-gate unit tests, 5 handler unit tests, 3 file-path
unit tests, 12 permission-handler unit tests, 7 integration tests
(AC-1..AC-6, AC-8). All 253 unit tests + smoke integration suite
green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:50:49 +03:00

214 lines
7.7 KiB
C#

using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
using SatelliteProvider.Services.TileDownloader;
using SatelliteProvider.Tests.TestUtilities;
namespace SatelliteProvider.Tests;
public class UavTileUploadHandlerTests : IDisposable
{
private readonly string _tilesRoot;
public UavTileUploadHandlerTests()
{
_tilesRoot = Path.Combine(Path.GetTempPath(), "satprov-uavtests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tilesRoot);
}
public void Dispose()
{
if (Directory.Exists(_tilesRoot))
{
Directory.Delete(_tilesRoot, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task HandleAsync_HappyPath_PersistsRowAndAcceptsItem()
{
// Arrange
var jpeg = UavTileImageFactory.CreateRandomJpeg();
var metadata = ValidMetadata();
var (handler, repo) = BuildHandler();
var inserted = new List<TileEntity>();
repo.Setup(r => r.InsertAsync(It.IsAny<TileEntity>()))
.ReturnsAsync(Guid.NewGuid())
.Callback<TileEntity>(e => inserted.Add(e));
// Act
var result = await handler.HandleAsync(
JsonSerializer.Serialize(new UavTileBatchMetadataPayload { Items = { metadata } }),
new List<UavUploadFile> { new("tile.jpg", "image/jpeg", jpeg) });
// Assert
result.EnvelopeRejected.Should().BeFalse();
result.Response!.Items.Should().HaveCount(1);
result.Response.Items[0].Status.Should().Be(UavTileUploadStatus.Accepted);
result.Response.Items[0].TileId.Should().NotBeNull();
inserted.Should().HaveCount(1);
inserted[0].Source.Should().Be(TileSourceConverter.ToWireValue(TileSource.Uav));
inserted[0].FilePath.Should().Contain(Path.Combine("uav", "18"));
File.Exists(inserted[0].FilePath).Should().BeTrue();
}
[Fact]
public async Task HandleAsync_MixedBatch_AcceptsValidRejectsBadItems()
{
// Arrange
var goodJpeg = UavTileImageFactory.CreateRandomJpeg();
var wrongDimensions = UavTileImageFactory.CreateRandomJpeg(width: 512, height: 512);
var notJpeg = new byte[6000];
notJpeg[0] = 0x89; notJpeg[1] = 0x50; notJpeg[2] = 0x4E; notJpeg[3] = 0x47;
var meta = ValidMetadata();
var (handler, repo) = BuildHandler();
repo.Setup(r => r.InsertAsync(It.IsAny<TileEntity>())).ReturnsAsync(Guid.NewGuid());
// Act
var result = await handler.HandleAsync(
JsonSerializer.Serialize(new UavTileBatchMetadataPayload
{
Items = { meta, meta with { Latitude = meta.Latitude + 0.001 }, meta with { Latitude = meta.Latitude + 0.002 } }
}),
new List<UavUploadFile>
{
new("a.jpg", "image/jpeg", goodJpeg),
new("b.jpg", "image/jpeg", wrongDimensions),
new("c.jpg", "image/jpeg", notJpeg),
});
// Assert
result.EnvelopeRejected.Should().BeFalse();
result.Response!.Items.Should().HaveCount(3);
result.Response.Items[0].Status.Should().Be(UavTileUploadStatus.Accepted);
result.Response.Items[1].Status.Should().Be(UavTileUploadStatus.Rejected);
result.Response.Items[1].RejectReason.Should().Be(UavTileRejectReasons.WrongDimensions);
result.Response.Items[2].Status.Should().Be(UavTileUploadStatus.Rejected);
result.Response.Items[2].RejectReason.Should().Be(UavTileRejectReasons.InvalidFormat);
repo.Verify(r => r.InsertAsync(It.IsAny<TileEntity>()), Times.Once,
"only the single accepted item should reach the repository");
}
[Fact]
public async Task HandleAsync_OversizedBatch_ReturnsEnvelopeError()
{
// Arrange
var (handler, _) = BuildHandler(new UavQualityConfig { MaxBatchSize = 2 });
var metadata = ValidMetadata();
var payload = new UavTileBatchMetadataPayload { Items = { metadata, metadata, metadata } };
var jpeg = UavTileImageFactory.CreateRandomJpeg();
// Act
var result = await handler.HandleAsync(
JsonSerializer.Serialize(payload),
new List<UavUploadFile>
{
new("a.jpg", "image/jpeg", jpeg),
new("b.jpg", "image/jpeg", jpeg),
new("c.jpg", "image/jpeg", jpeg),
});
// Assert
result.EnvelopeRejected.Should().BeTrue();
result.EnvelopeError.Should().Contain("exceeds the configured maximum");
result.Response.Should().BeNull();
}
[Fact]
public async Task HandleAsync_MismatchedFilesAndMetadata_ReturnsEnvelopeError()
{
// Arrange
var (handler, _) = BuildHandler();
var jpeg = UavTileImageFactory.CreateRandomJpeg();
// Act
var result = await handler.HandleAsync(
JsonSerializer.Serialize(new UavTileBatchMetadataPayload { Items = { ValidMetadata(), ValidMetadata() } }),
new List<UavUploadFile> { new("a.jpg", "image/jpeg", jpeg) });
// Assert
result.EnvelopeRejected.Should().BeTrue();
result.EnvelopeError.Should().Contain("Mismatched batch");
}
[Fact]
public async Task HandleAsync_EmptyMetadata_ReturnsEnvelopeError()
{
// Arrange
var (handler, _) = BuildHandler();
// Act
var result = await handler.HandleAsync(
metadataJson: "",
files: new List<UavUploadFile>());
// Assert
result.EnvelopeRejected.Should().BeTrue();
}
[Fact]
public async Task HandleAsync_InvalidMetadataJson_ReturnsEnvelopeError()
{
// Arrange
var (handler, _) = BuildHandler();
// Act
var result = await handler.HandleAsync(
metadataJson: "{ not valid json",
files: new List<UavUploadFile>());
// Assert
result.EnvelopeRejected.Should().BeTrue();
result.EnvelopeError.Should().Contain("Invalid `metadata` JSON");
}
private (UavTileUploadHandler Handler, Mock<ITileRepository> Repo) BuildHandler(UavQualityConfig? quality = null)
{
var qualityConfig = quality ?? new UavQualityConfig();
var mapConfig = new MapConfig { TileSizePixels = 256 };
var storageConfig = new StorageConfig { TilesDirectory = _tilesRoot };
var time = new FixedTimeProvider(new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc));
var gate = new UavTileQualityGate(
Options.Create(qualityConfig),
Options.Create(mapConfig),
time);
var repo = new Mock<ITileRepository>();
var handler = new UavTileUploadHandler(
gate,
repo.Object,
Options.Create(storageConfig),
Options.Create(mapConfig),
Options.Create(qualityConfig),
NullLogger<UavTileUploadHandler>.Instance,
time);
return (handler, repo);
}
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) => _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero);
}
}