[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
@@ -137,4 +137,107 @@ public class EnumStringTypeHandlerTests
DapperEnumTypeHandlers.RegisterAll();
DapperEnumTypeHandlers.RegisterAll();
}
[Theory]
[InlineData(TileSource.GoogleMaps, "google_maps")]
[InlineData(TileSource.Uav, "uav")]
public void TileSourceHandler_SetValue_WritesContractWireValue_AZ484(TileSource value, string expected)
{
// Arrange
var handler = new TileSourceTypeHandler();
var param = new NpgsqlParameter();
// Act
handler.SetValue(param, value);
// Assert
param.Value.Should().Be(expected);
param.DbType.Should().Be(DbType.String);
}
[Theory]
[InlineData("google_maps", TileSource.GoogleMaps)]
[InlineData("GOOGLE_MAPS", TileSource.GoogleMaps)]
[InlineData("uav", TileSource.Uav)]
[InlineData("UAV", TileSource.Uav)]
public void TileSourceHandler_Parse_AcceptsContractWireValue_AZ484(string raw, TileSource expected)
{
// Arrange
var handler = new TileSourceTypeHandler();
// Act
var result = handler.Parse(raw);
// Assert
result.Should().Be(expected);
}
[Theory]
[InlineData(TileSource.GoogleMaps)]
[InlineData(TileSource.Uav)]
public void TileSourceHandler_RoundTrip_PreservesValue_AZ484(TileSource value)
{
// Arrange
var handler = new TileSourceTypeHandler();
var param = new NpgsqlParameter();
handler.SetValue(param, value);
// Act
var roundTripped = handler.Parse(param.Value!);
// Assert
roundTripped.Should().Be(value);
}
[Fact]
public void TileSourceHandler_Parse_UnknownString_ThrowsDataException_AZ484()
{
// Arrange
var handler = new TileSourceTypeHandler();
// Act
Action act = () => handler.Parse("satar");
// Assert — Inv-1: unknown sources surface, not silently coerce (coderule.mdc: never suppress errors).
act.Should().Throw<DataException>().WithMessage("*satar*");
}
[Fact]
public void TileSourceHandler_Parse_NullValue_ThrowsDataException_AZ484()
{
// Arrange
var handler = new TileSourceTypeHandler();
// Act
Action act = () => handler.Parse(null!);
// Assert
act.Should().Throw<DataException>();
}
[Fact]
public void TileSourceHandler_Parse_DbNullValue_ThrowsDataException_AZ484()
{
// Arrange
var handler = new TileSourceTypeHandler();
// Act
Action act = () => handler.Parse(DBNull.Value);
// Assert
act.Should().Throw<DataException>();
}
[Fact]
public void RegisterAll_RegistersTileSourceHandler_AZ484()
{
// Arrange / Act
DapperEnumTypeHandlers.RegisterAll();
// Assert — registration is idempotent and the handler emits the contract wire value.
var probe = new TileSourceTypeHandler();
var param = new NpgsqlParameter();
probe.SetValue(param, TileSource.GoogleMaps);
param.Value.Should().Be("google_maps");
}
}
@@ -125,7 +125,8 @@ public class RepositoryRefactorTests
"latitude", "longitude",
"tile_size_meters as TileSizeMeters", "tile_size_pixels as TileSizePixels",
"image_type as ImageType", "maps_version as MapsVersion", "version",
"file_path as FilePath", "created_at as CreatedAt", "updated_at as UpdatedAt"
"file_path as FilePath", "source", "captured_at as CapturedAt",
"created_at as CreatedAt", "updated_at as UpdatedAt"
};
var regionColumns = new[]
{
+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()
{