Files
satellite-provider/SatelliteProvider.Tests/RepositoryRefactorTests.cs
T
Oleksandr Bezdieniezhnykh 687d6bdd5b [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>
2026-05-11 06:21:59 +03:00

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