Files
satellite-provider/SatelliteProvider.Tests/RegionServiceTests.cs
T
Oleksandr Bezdieniezhnykh 2393bff1f2 [AZ-362] Refactor C09: idempotent POST contract for caller-supplied GUIDs
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>
2026-05-11 00:45:51 +03:00

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);
}
}