mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 18:01:16 +00:00
2393bff1f2
Both POST /api/satellite/request and POST /api/satellite/route accept a caller-supplied id (Guid). Before this change, a retried POST with the same id would either crash with a unique-key violation (regions) or quietly create a divergent row (routes), neither of which matched the documented intent of caller-supplied GUIDs. RegionService.RequestRegionAsync and RouteService.CreateRouteAsync now check for an existing row by id at the top of the method. If one is found, the existing resource is returned with HTTP 200 and the side effects (insert + enqueue + point regeneration + geofence-region queueing) are all skipped. The Information-level log line on the idempotent path makes retries observable. OpenAPI Description metadata documents the contract on both endpoints so client integrators see it in Swagger. Coverage: - 2 new unit tests (one per service) assert that on duplicate id no insert / enqueue / point-generation / region-queueing call is made. - 2 new integration tests (IdempotentPostTests.cs) exercise the contract end-to-end via HTTP, asserting both calls return 200 and CreatedAt matches within 1ms (PostgreSQL truncates TIMESTAMP to microseconds while .NET DateTime keeps 100ns ticks; a real re-insertion would shift CreatedAt by milliseconds at minimum). Note: the check-first pattern leaves a TOCTOU window for concurrent retries. The repository unique key still surfaces the race as a PostgresException which AZ-353 maps to a clean error. Acceptable for realistic sequential-retry patterns; recorded in batch report as a non-blocking observation. Co-authored-by: Cursor <cursoragent@cursor.com>
320 lines
12 KiB
C#
320 lines
12 KiB
C#
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Exceptions;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
using SatelliteProvider.Services.RegionProcessing;
|
|
|
|
namespace SatelliteProvider.Tests;
|
|
|
|
public class RegionServiceTests : IDisposable
|
|
{
|
|
private readonly string _readyDir;
|
|
|
|
public RegionServiceTests()
|
|
{
|
|
_readyDir = Path.Combine(Path.GetTempPath(), "sp-region-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_readyDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_readyDir))
|
|
{
|
|
Directory.Delete(_readyDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
private RegionService BuildService(
|
|
Mock<IRegionRepository> regionRepo,
|
|
Mock<IRegionRequestQueue> queue,
|
|
Mock<ITileService> tileService)
|
|
{
|
|
var storage = Options.Create(new StorageConfig { ReadyDirectory = _readyDir, TilesDirectory = "/tiles" });
|
|
return new RegionService(regionRepo.Object, queue.Object, tileService.Object, storage, NullLogger<RegionService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRegionAsync_InsertsEntityAndQueues_BT03_AC1()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
regionRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((RegionEntity?)null);
|
|
var queue = new Mock<IRegionRequestQueue>();
|
|
var tileService = new Mock<ITileService>();
|
|
var service = BuildService(regionRepo, queue, tileService);
|
|
|
|
var id = Guid.NewGuid();
|
|
|
|
// Act
|
|
var status = await service.RequestRegionAsync(id, 47.461747, 37.647063, 200, 18);
|
|
|
|
// Assert
|
|
status.Id.Should().Be(id);
|
|
status.Status.Should().Be("queued");
|
|
regionRepo.Verify(r => r.InsertAsync(It.Is<RegionEntity>(re =>
|
|
re.Id == id &&
|
|
re.Status == "queued" &&
|
|
re.SizeMeters == 200 &&
|
|
re.ZoomLevel == 18)), Times.Once);
|
|
queue.Verify(q => q.EnqueueAsync(It.Is<RegionRequest>(rr =>
|
|
rr.Id == id &&
|
|
rr.SizeMeters == 200 &&
|
|
rr.ZoomLevel == 18 &&
|
|
rr.StitchTiles == false), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRegionAsync_DuplicateId_ReturnsExistingResource_NoReQueue_AZ362_AC1()
|
|
{
|
|
// Arrange
|
|
var id = Guid.NewGuid();
|
|
var existing = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = 47.461747,
|
|
Longitude = 37.647063,
|
|
SizeMeters = 200,
|
|
ZoomLevel = 18,
|
|
StitchTiles = false,
|
|
Status = "processing",
|
|
TilesDownloaded = 5,
|
|
TilesReused = 3,
|
|
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
|
|
UpdatedAt = DateTime.UtcNow.AddMinutes(-1),
|
|
};
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(existing);
|
|
var queue = new Mock<IRegionRequestQueue>(MockBehavior.Strict);
|
|
var tileService = new Mock<ITileService>();
|
|
var service = BuildService(regionRepo, queue, tileService);
|
|
|
|
// Act
|
|
var status = await service.RequestRegionAsync(id, 47.461747, 37.647063, 200, 18);
|
|
|
|
// Assert
|
|
status.Id.Should().Be(id);
|
|
status.Status.Should().Be("processing", "AZ-362: returns the existing region's current status, not 'queued'");
|
|
regionRepo.Verify(r => r.InsertAsync(It.IsAny<RegionEntity>()), Times.Never,
|
|
"AZ-362: duplicate Id must not re-insert");
|
|
queue.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessRegionAsync_HappyPath_TransitionsToCompletedAndWritesArtifacts_BT03_AC2_AC3()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
var queue = new Mock<IRegionRequestQueue>();
|
|
var tileService = new Mock<ITileService>();
|
|
|
|
var id = Guid.NewGuid();
|
|
var entity = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = 47.461747,
|
|
Longitude = 37.647063,
|
|
SizeMeters = 200,
|
|
ZoomLevel = 18,
|
|
Status = "queued",
|
|
StitchTiles = false,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
|
|
|
var capturedStatuses = new List<string>();
|
|
regionRepo
|
|
.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>()))
|
|
.Callback<RegionEntity>(e => capturedStatuses.Add(e.Status))
|
|
.ReturnsAsync(1);
|
|
|
|
tileService
|
|
.Setup(t => t.GetTilesByRegionAsync(entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel))
|
|
.ReturnsAsync(Array.Empty<TileMetadata>());
|
|
tileService
|
|
.Setup(t => t.DownloadAndStoreTilesAsync(
|
|
entity.Latitude, entity.Longitude, entity.SizeMeters, entity.ZoomLevel,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<TileMetadata>
|
|
{
|
|
new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Latitude = entity.Latitude,
|
|
Longitude = entity.Longitude,
|
|
TileZoom = entity.ZoomLevel,
|
|
TileSizePixels = 256,
|
|
ImageType = "jpg",
|
|
FilePath = "tiles/18/0/0/x.jpg",
|
|
},
|
|
});
|
|
|
|
var service = BuildService(regionRepo, queue, tileService);
|
|
|
|
// Act
|
|
await service.ProcessRegionAsync(id);
|
|
|
|
// Assert
|
|
capturedStatuses.Should().ContainInOrder("processing", "completed");
|
|
entity.Status.Should().Be("completed");
|
|
entity.CsvFilePath.Should().NotBeNullOrEmpty();
|
|
entity.SummaryFilePath.Should().NotBeNullOrEmpty();
|
|
entity.TilesDownloaded.Should().Be(1);
|
|
entity.TilesReused.Should().Be(0);
|
|
File.Exists(entity.CsvFilePath!).Should().BeTrue("CSV file is written to ReadyDirectory");
|
|
File.Exists(entity.SummaryFilePath!).Should().BeTrue("summary file is written to ReadyDirectory");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessRegionAsync_MissingRegionId_LogsAndReturns()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
regionRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((RegionEntity?)null);
|
|
var service = BuildService(regionRepo, new Mock<IRegionRequestQueue>(), new Mock<ITileService>());
|
|
|
|
// Act
|
|
await service.ProcessRegionAsync(Guid.NewGuid());
|
|
|
|
// Assert
|
|
regionRepo.Verify(r => r.UpdateAsync(It.IsAny<RegionEntity>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessRegionAsync_DownloaderFailure_TransitionsToFailedAndWritesErrorSummary()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
var queue = new Mock<IRegionRequestQueue>();
|
|
var tileService = new Mock<ITileService>();
|
|
|
|
var id = Guid.NewGuid();
|
|
var entity = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = 47.461747,
|
|
Longitude = 37.647063,
|
|
SizeMeters = 200,
|
|
ZoomLevel = 18,
|
|
Status = "queued",
|
|
StitchTiles = false,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
|
|
|
var capturedStatuses = new List<string>();
|
|
regionRepo
|
|
.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>()))
|
|
.Callback<RegionEntity>(e => capturedStatuses.Add(e.Status))
|
|
.ReturnsAsync(1);
|
|
|
|
tileService
|
|
.Setup(t => t.GetTilesByRegionAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>()))
|
|
.ReturnsAsync(Array.Empty<TileMetadata>());
|
|
tileService
|
|
.Setup(t => t.DownloadAndStoreTilesAsync(
|
|
It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new RateLimitException("Google Maps rate limit exceeded"));
|
|
|
|
var service = BuildService(regionRepo, queue, tileService);
|
|
|
|
// Act
|
|
await service.ProcessRegionAsync(id);
|
|
|
|
// Assert
|
|
capturedStatuses.Should().ContainInOrder("processing", "failed");
|
|
entity.Status.Should().Be("failed");
|
|
entity.SummaryFilePath.Should().NotBeNullOrEmpty();
|
|
File.Exists(entity.SummaryFilePath!).Should().BeTrue();
|
|
File.ReadAllText(entity.SummaryFilePath!).Should().Contain("Rate limit exceeded");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessRegionAsync_StitchEnabled_SetsStitchedImagePath_BT05_AC4()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
var queue = new Mock<IRegionRequestQueue>();
|
|
var tileService = new Mock<ITileService>();
|
|
|
|
var id = Guid.NewGuid();
|
|
var entity = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = 47.461747,
|
|
Longitude = 37.647063,
|
|
SizeMeters = 500,
|
|
ZoomLevel = 18,
|
|
Status = "queued",
|
|
StitchTiles = true,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
regionRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
|
|
regionRepo.Setup(r => r.UpdateAsync(It.IsAny<RegionEntity>())).ReturnsAsync(1);
|
|
|
|
tileService
|
|
.Setup(t => t.GetTilesByRegionAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>()))
|
|
.ReturnsAsync(Array.Empty<TileMetadata>());
|
|
tileService
|
|
.Setup(t => t.DownloadAndStoreTilesAsync(
|
|
It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<TileMetadata>
|
|
{
|
|
new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Latitude = entity.Latitude,
|
|
Longitude = entity.Longitude,
|
|
TileZoom = entity.ZoomLevel,
|
|
TileSizePixels = 256,
|
|
ImageType = "jpg",
|
|
// Path doesn't exist on disk — stitcher will skip but still produce an empty image.
|
|
FilePath = Path.Combine(_readyDir, "missing.jpg"),
|
|
},
|
|
});
|
|
|
|
var service = BuildService(regionRepo, queue, tileService);
|
|
|
|
// Act
|
|
await service.ProcessRegionAsync(id);
|
|
|
|
// Assert
|
|
entity.Status.Should().Be("completed");
|
|
var stitchedPath = Path.Combine(_readyDir, $"region_{id}_stitched.jpg");
|
|
File.Exists(stitchedPath).Should().BeTrue("stitched image must exist when stitchTiles=true");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRegionStatusAsync_KnownId_ReturnsMappedStatus()
|
|
{
|
|
// Arrange
|
|
var regionRepo = new Mock<IRegionRepository>();
|
|
var id = Guid.NewGuid();
|
|
regionRepo.Setup(r => r.GetByIdAsync(id))
|
|
.ReturnsAsync(new RegionEntity { Id = id, Status = "completed", TilesDownloaded = 5, TilesReused = 2 });
|
|
var service = BuildService(regionRepo, new Mock<IRegionRequestQueue>(), new Mock<ITileService>());
|
|
|
|
// Act
|
|
var result = await service.GetRegionStatusAsync(id);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result!.Status.Should().Be("completed");
|
|
result.TilesDownloaded.Should().Be(5);
|
|
result.TilesReused.Should().Be(2);
|
|
}
|
|
}
|