mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 13:21:14 +00:00
687d6bdd5b
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>
197 lines
8.0 KiB
C#
197 lines
8.0 KiB
C#
using System.Reflection;
|
|
using FluentAssertions;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
|
|
namespace SatelliteProvider.Tests;
|
|
|
|
public class RepositoryRefactorTests
|
|
{
|
|
[Fact]
|
|
public void TileRepository_DoesNotExposeFindExistingTileAsync_AZ376_AC1()
|
|
{
|
|
// Arrange
|
|
var interfaceType = typeof(ITileRepository);
|
|
var concreteType = typeof(TileRepository);
|
|
|
|
// Assert
|
|
interfaceType.GetMethod("FindExistingTileAsync").Should().BeNull(
|
|
"AZ-376 deletes the unused FindExistingTileAsync method from ITileRepository");
|
|
concreteType.GetMethod("FindExistingTileAsync").Should().BeNull(
|
|
"AZ-376 deletes the unused FindExistingTileAsync implementation from TileRepository");
|
|
}
|
|
|
|
[Fact]
|
|
public void TileRepository_KeepsAndUsesLogger_AZ378_AC1()
|
|
{
|
|
// Arrange
|
|
var path = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "TileRepository.cs"));
|
|
path.Should().NotBeNull();
|
|
var content = File.ReadAllText(path!);
|
|
|
|
// Assert
|
|
content.Should().Contain("ILogger<TileRepository>",
|
|
"TileRepository keeps the logger field per AZ-378 recommended split");
|
|
content.Should().Contain("_logger.LogWarning",
|
|
"AZ-378 AC-1 requires the kept logger to actually be used (slow-query warning)");
|
|
content.Should().Contain("SlowQueryThresholdMs",
|
|
"Slow-query threshold is a named const per the task spec");
|
|
}
|
|
|
|
[Fact]
|
|
public void RegionRepository_HasNoUnusedLoggerParameter_AZ378_AC2()
|
|
{
|
|
// Arrange
|
|
var ctors = typeof(RegionRepository).GetConstructors(BindingFlags.Public | BindingFlags.Instance);
|
|
|
|
// Assert
|
|
ctors.Should().HaveCount(1);
|
|
ctors[0].GetParameters().Should().OnlyContain(p => p.ParameterType == typeof(string),
|
|
"AZ-378 removes the unused ILogger<RegionRepository> parameter; only the connection string remains");
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteRepository_HasNoUnusedLoggerParameter_AZ378_AC2()
|
|
{
|
|
// Arrange
|
|
var ctors = typeof(RouteRepository).GetConstructors(BindingFlags.Public | BindingFlags.Instance);
|
|
|
|
// Assert
|
|
ctors.Should().HaveCount(1);
|
|
ctors[0].GetParameters().Should().OnlyContain(p => p.ParameterType == typeof(string),
|
|
"AZ-378 removes the unused ILogger<RouteRepository> parameter; only the connection string remains");
|
|
}
|
|
|
|
[Fact]
|
|
public void TileRepository_DefinesColumnListConstantOnce_AZ379_AC1()
|
|
{
|
|
// Arrange
|
|
var path = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "TileRepository.cs"));
|
|
path.Should().NotBeNull();
|
|
var content = File.ReadAllText(path!);
|
|
|
|
// Assert
|
|
CountOccurrences(content, "private const string ColumnList").Should().Be(1,
|
|
"AZ-379 AC-1 requires exactly one ColumnList constant per repository");
|
|
CountOccurrences(content, "{ColumnList}").Should().BeGreaterThanOrEqualTo(3,
|
|
"TileRepository has at least 3 SELECTs that should reuse {ColumnList} (GetById, GetByTileCoordinates, GetTilesByRegion)");
|
|
}
|
|
|
|
[Fact]
|
|
public void RegionRepository_DefinesColumnListConstantOnce_AZ379_AC1()
|
|
{
|
|
// Arrange
|
|
var path = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "RegionRepository.cs"));
|
|
path.Should().NotBeNull();
|
|
var content = File.ReadAllText(path!);
|
|
|
|
// Assert
|
|
CountOccurrences(content, "private const string ColumnList").Should().Be(1);
|
|
CountOccurrences(content, "{ColumnList}").Should().BeGreaterThanOrEqualTo(2,
|
|
"RegionRepository has 2 SELECTs that should reuse {ColumnList} (GetById, GetByStatus)");
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteRepository_DefinesColumnListConstantOnce_AZ379_AC1()
|
|
{
|
|
// Arrange
|
|
var path = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "RouteRepository.cs"));
|
|
path.Should().NotBeNull();
|
|
var content = File.ReadAllText(path!);
|
|
|
|
// Assert
|
|
CountOccurrences(content, "private const string ColumnList").Should().Be(1);
|
|
CountOccurrences(content, "{ColumnList}").Should().BeGreaterThanOrEqualTo(2,
|
|
"RouteRepository has 2 routes-table SELECTs that should reuse {ColumnList} (GetById, GetRoutesWithPendingMaps)");
|
|
}
|
|
|
|
[Fact]
|
|
public void RepositoryColumnLists_ContainExpectedColumns_AZ379_AC2()
|
|
{
|
|
// Arrange
|
|
var tilePath = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "TileRepository.cs"));
|
|
var regionPath = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "RegionRepository.cs"));
|
|
var routePath = LocateRepoFile(Path.Combine("SatelliteProvider.DataAccess", "Repositories", "RouteRepository.cs"));
|
|
tilePath.Should().NotBeNull();
|
|
regionPath.Should().NotBeNull();
|
|
routePath.Should().NotBeNull();
|
|
|
|
var tileContent = File.ReadAllText(tilePath!);
|
|
var regionContent = File.ReadAllText(regionPath!);
|
|
var routeContent = File.ReadAllText(routePath!);
|
|
|
|
var tileColumns = new[]
|
|
{
|
|
"id", "tile_zoom as TileZoom", "tile_x as TileX", "tile_y as TileY",
|
|
"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", "source", "captured_at as CapturedAt",
|
|
"created_at as CreatedAt", "updated_at as UpdatedAt"
|
|
};
|
|
var regionColumns = new[]
|
|
{
|
|
"id", "latitude", "longitude", "size_meters as SizeMeters",
|
|
"zoom_level as ZoomLevel", "status",
|
|
"csv_file_path as CsvFilePath", "summary_file_path as SummaryFilePath",
|
|
"tiles_downloaded as TilesDownloaded", "tiles_reused as TilesReused",
|
|
"stitch_tiles as StitchTiles",
|
|
"created_at as CreatedAt", "updated_at as UpdatedAt"
|
|
};
|
|
var routeColumns = new[]
|
|
{
|
|
"id", "name", "description", "region_size_meters as RegionSizeMeters",
|
|
"zoom_level as ZoomLevel", "total_distance_meters as TotalDistanceMeters",
|
|
"total_points as TotalPoints", "request_maps as RequestMaps",
|
|
"maps_ready as MapsReady", "create_tiles_zip as CreateTilesZip",
|
|
"csv_file_path as CsvFilePath",
|
|
"summary_file_path as SummaryFilePath", "stitched_image_path as StitchedImagePath",
|
|
"tiles_zip_path as TilesZipPath",
|
|
"created_at as CreatedAt", "updated_at as UpdatedAt"
|
|
};
|
|
|
|
// Assert
|
|
foreach (var column in tileColumns)
|
|
{
|
|
tileContent.Should().Contain(column,
|
|
$"TileRepository ColumnList must include '{column}' to keep generated SQL semantically identical");
|
|
}
|
|
foreach (var column in regionColumns)
|
|
{
|
|
regionContent.Should().Contain(column,
|
|
$"RegionRepository ColumnList must include '{column}' to keep generated SQL semantically identical");
|
|
}
|
|
foreach (var column in routeColumns)
|
|
{
|
|
routeContent.Should().Contain(column,
|
|
$"RouteRepository ColumnList must include '{column}' to keep generated SQL semantically identical");
|
|
}
|
|
}
|
|
|
|
private static int CountOccurrences(string haystack, string needle)
|
|
{
|
|
var count = 0;
|
|
var index = 0;
|
|
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) != -1)
|
|
{
|
|
count++;
|
|
index += needle.Length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static string? LocateRepoFile(string relativePath)
|
|
{
|
|
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
|
while (dir is not null)
|
|
{
|
|
var candidate = Path.Combine(dir.FullName, relativePath);
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
dir = dir.Parent;
|
|
}
|
|
return null;
|
|
}
|
|
}
|