[AZ-484] Multi-source tile storage: source + captured_at

Add per-source tile rows to support multi-provider imagery (Google
Maps + future UAV). Migration 013 (transactional) introduces
source/captured_at columns, backfills existing rows to
(source='google_maps', captured_at=created_at), and replaces the
4-column unique index with a 5-column index that includes source.

TileRepository:
- ColumnList includes source + captured_at
- GetByTileCoordinatesAsync returns most-recent row across sources
  (ORDER BY captured_at DESC, updated_at DESC, id DESC)
- GetTilesByRegionAsync uses DISTINCT ON to pick the most-recent
  tile per cell, restoring caller-facing row order
- Insert/Update upsert on the new 5-column conflict key

TileSource enum lives in Common.Enums. Snake_case wire format
(google_maps, uav) is enforced by a focused TileSourceTypeHandler
because the generic ToLowerInvariant pattern would emit
"googlemaps", violating contract v1.0.0.

TileService stamps Source=GoogleMaps + CapturedAt=UtcNow on every
new tile. Tile-storage contract is now frozen at v1.0.0.

AC coverage 7/7. New unit + integration tests cover all ACs;
existing 200 unit + 5 smoke tests preserved.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 06:21:59 +03:00
parent 5ba58b6c8d
commit 687d6bdd5b
21 changed files with 884 additions and 48 deletions
+49 -1
View File
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using Moq;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
@@ -93,6 +94,8 @@ public class TileServiceTests
FilePath = "tiles/18/0/0/cached.jpg",
TileSizePixels = 256,
ImageType = "jpg",
Source = TileSource.GoogleMaps,
CapturedAt = DateTime.UtcNow,
},
};
tileRepo
@@ -148,6 +151,8 @@ public class TileServiceTests
FilePath = "tiles/18/0/0/cached_prior_year.jpg",
TileSizePixels = 256,
ImageType = "jpg",
Source = TileSource.GoogleMaps,
CapturedAt = DateTime.UtcNow.AddYears(-1),
},
};
tileRepo
@@ -271,6 +276,8 @@ public class TileServiceTests
ImageType = "jpg",
FilePath = "tiles/18/0/0/x.jpg",
Version = 2026,
Source = TileSource.GoogleMaps,
CapturedAt = DateTime.UtcNow,
};
var tileRepo = new Mock<ITileRepository>();
tileRepo.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(entity);
@@ -339,7 +346,16 @@ public class TileServiceTests
var tileRepo = new Mock<ITileRepository>();
tileRepo
.Setup(r => r.GetByTileCoordinatesAsync(z, x, y))
.ReturnsAsync(new TileEntity { Id = Guid.NewGuid(), TileZoom = z, TileX = x, TileY = y, FilePath = tempPath });
.ReturnsAsync(new TileEntity
{
Id = Guid.NewGuid(),
TileZoom = z,
TileX = x,
TileY = y,
FilePath = tempPath,
Source = TileSource.GoogleMaps,
CapturedAt = DateTime.UtcNow,
});
var service = BuildService(downloader, tileRepo);
@@ -444,6 +460,38 @@ public class TileServiceTests
capturedToken.Should().Be(cts.Token, "AZ-371 AC-3: caller-supplied CT must reach the downloader");
}
[Fact]
public void BuildTileEntity_SetsGoogleMapsSourceAndUtcCapturedAt_AZ484_AC5()
{
// Arrange
var downloader = new Mock<ISatelliteDownloader>();
var tileRepo = new Mock<ITileRepository>();
TileEntity? captured = null;
tileRepo
.Setup(r => r.InsertAsync(It.IsAny<TileEntity>()))
.Callback<TileEntity>(e => captured = e)
.ReturnsAsync(Guid.NewGuid());
downloader
.Setup(d => d.DownloadSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DownloadedTileInfoV2(1, 2, 18, 47.46, 37.65, "tiles/18/1/2.jpg", 100.0));
var service = BuildService(downloader, tileRepo);
var before = DateTime.UtcNow;
// Act
_ = service.DownloadAndStoreSingleTileAsync(47.46, 37.65, 18).GetAwaiter().GetResult();
// Assert
var after = DateTime.UtcNow;
captured.Should().NotBeNull();
captured!.Source.Should().Be(TileSource.GoogleMaps,
"AZ-484 AC-5: the Google Maps download path stamps Source=GoogleMaps");
captured.CapturedAt.Kind.Should().NotBe(DateTimeKind.Local,
"captured_at must be UTC per the v1.0.0 storage contract");
captured.CapturedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(after,
"AZ-484 AC-5: CapturedAt is the UtcNow at download time");
}
[Fact]
public async Task DownloadAndStoreSingleTileAsync_DownloaderThrows_DoesNotInsert_AZ311_AC2b()
{