mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-26 07:51:13 +00:00
[AZ-1074] [AZ-1075] gRPC tile stream tests and shared proto
Extract tile_provision.proto into GrpcContracts, add integration tests and validation hardening for DeliverRouteTiles streaming. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj", "SatelliteProvider.IntegrationTests/"]
|
||||
COPY ["SatelliteProvider.GrpcContracts/SatelliteProvider.GrpcContracts.csproj", "SatelliteProvider.GrpcContracts/"]
|
||||
COPY ["SatelliteProvider.TestSupport/SatelliteProvider.TestSupport.csproj", "SatelliteProvider.TestSupport/"]
|
||||
COPY ["SatelliteProvider.Common/SatelliteProvider.Common.csproj", "SatelliteProvider.Common/"]
|
||||
RUN dotnet restore "SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/SatelliteProvider.IntegrationTests"
|
||||
@@ -10,7 +12,7 @@ RUN dotnet build "SatelliteProvider.IntegrationTests.csproj" -c Release -o /app/
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "SatelliteProvider.IntegrationTests.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "SatelliteProvider.IntegrationTests.dll"]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Satellite.V1;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class GrpcTestHelpers
|
||||
{
|
||||
public static GrpcChannel CreateChannel(string apiUrl)
|
||||
{
|
||||
return GrpcChannel.ForAddress(apiUrl, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = new SocketsHttpHandler
|
||||
{
|
||||
EnableMultipleHttp2Connections = true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static RouteTileDelivery.RouteTileDeliveryClient CreateClient(string apiUrl, string jwtToken)
|
||||
{
|
||||
var channel = CreateChannel(apiUrl);
|
||||
var client = new RouteTileDelivery.RouteTileDeliveryClient(channel);
|
||||
return client;
|
||||
}
|
||||
|
||||
public static Metadata AuthMetadata(string jwtToken)
|
||||
{
|
||||
return new Metadata { { "Authorization", $"Bearer {jwtToken}" } };
|
||||
}
|
||||
|
||||
public static DeliverRouteTilesRequest BuildValidRequest(
|
||||
Guid? routeId = null,
|
||||
double lat1 = 48.276067180586544,
|
||||
double lon1 = 37.38445758819581,
|
||||
double lat2 = 48.27074009522731,
|
||||
double lon2 = 37.374029159545906,
|
||||
double regionSizeMeters = 500,
|
||||
int zoom = 18)
|
||||
{
|
||||
return new DeliverRouteTilesRequest
|
||||
{
|
||||
Route = new RouteSpec
|
||||
{
|
||||
RouteId = (routeId ?? Guid.NewGuid()).ToString(),
|
||||
RegionSizeMeters = regionSizeMeters,
|
||||
Zoom = zoom,
|
||||
Waypoints =
|
||||
{
|
||||
new Waypoint { Lat = lat1, Lon = lon1 },
|
||||
new Waypoint { Lat = lat2, Lon = lon2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<(RouteManifest? Manifest, List<TilePayload> Tiles, DeliveryComplete? Complete, DeliveryError? Error)>
|
||||
CollectStreamAsync(AsyncServerStreamingCall<RouteTileEvent> call, int? delayMsBetweenEvents = null)
|
||||
{
|
||||
RouteManifest? manifest = null;
|
||||
var tiles = new List<TilePayload>();
|
||||
DeliveryComplete? complete = null;
|
||||
DeliveryError? error = null;
|
||||
|
||||
await foreach (var evt in call.ResponseStream.ReadAllAsync())
|
||||
{
|
||||
switch (evt.PayloadCase)
|
||||
{
|
||||
case RouteTileEvent.PayloadOneofCase.Manifest:
|
||||
manifest = evt.Manifest;
|
||||
break;
|
||||
case RouteTileEvent.PayloadOneofCase.Batch:
|
||||
tiles.AddRange(evt.Batch.Tiles);
|
||||
break;
|
||||
case RouteTileEvent.PayloadOneofCase.Complete:
|
||||
complete = evt.Complete;
|
||||
break;
|
||||
case RouteTileEvent.PayloadOneofCase.Error:
|
||||
error = evt.Error;
|
||||
break;
|
||||
}
|
||||
|
||||
if (delayMsBetweenEvents.HasValue)
|
||||
{
|
||||
await Task.Delay(delayMsBetweenEvents.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return (manifest, tiles, complete, error);
|
||||
}
|
||||
|
||||
public static async Task ExpectInvalidArgumentAsync(Func<Task> action, string context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action();
|
||||
throw new Exception($"{context}: expected RpcException with InvalidArgument, but call succeeded");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
|
||||
{
|
||||
Console.WriteLine($" ✓ {context}: InvalidArgument — {ex.Status.Detail}");
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"{context}: expected InvalidArgument, got {ex.StatusCode} — {ex.Status.Detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,11 @@ class Program
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
{
|
||||
await RunSmokeSuite(httpClient, connectionString);
|
||||
await RunSmokeSuite(httpClient, connectionString, apiUrl, jwtSecret);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunFullSuite(httpClient, connectionString);
|
||||
await RunFullSuite(httpClient, connectionString, apiUrl, jwtSecret);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
@@ -130,7 +130,7 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunSmokeSuite(HttpClient httpClient, string connectionString)
|
||||
static async Task RunSmokeSuite(HttpClient httpClient, string connectionString, string apiUrl, string jwtSecret)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
@@ -145,11 +145,12 @@ class Program
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await RouteTileDeliveryGrpcTests.RunAll(httpClient, apiUrl, jwtSecret);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task RunFullSuite(HttpClient httpClient, string connectionString)
|
||||
static async Task RunFullSuite(HttpClient httpClient, string connectionString, string apiUrl, string jwtSecret)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
@@ -173,6 +174,7 @@ class Program
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await RouteTileDeliveryGrpcTests.RunAll(httpClient, apiUrl, jwtSecret);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Security.Cryptography;
|
||||
using Grpc.Core;
|
||||
using Satellite.V1;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class RouteTileDeliveryGrpcTests
|
||||
{
|
||||
private const double Lat1 = 48.276067180586544;
|
||||
private const double Lon1 = 37.38445758819581;
|
||||
private const double Lat2 = 48.27074009522731;
|
||||
private const double Lon2 = 37.374029159545906;
|
||||
private const double RegionSizeMeters = 500;
|
||||
private const int Zoom = 18;
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient, string apiUrl, string secret)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: gRPC RouteTileDelivery (AZ-1074 / AZ-1075)");
|
||||
|
||||
var token = JwtTestHelpers.MintAuthenticated(secret);
|
||||
var client = GrpcTestHelpers.CreateClient(apiUrl, token);
|
||||
var headers = GrpcTestHelpers.AuthMetadata(token);
|
||||
|
||||
await RunHappyPath(client, headers);
|
||||
await RunInvalidRequests(client, headers);
|
||||
await RunBackpressureSafe(client, headers);
|
||||
await RunRestConsistency(httpClient, client, headers);
|
||||
}
|
||||
|
||||
private static async Task RunHappyPath(RouteTileDelivery.RouteTileDeliveryClient client, Metadata headers)
|
||||
{
|
||||
RouteTestHelpers.PrintSectionHeader("AC-1: Happy path streams tiles");
|
||||
|
||||
var request = GrpcTestHelpers.BuildValidRequest(
|
||||
lat1: Lat1, lon1: Lon1, lat2: Lat2, lon2: Lon2,
|
||||
regionSizeMeters: RegionSizeMeters, zoom: Zoom);
|
||||
|
||||
using var call = client.DeliverRouteTiles(request, headers);
|
||||
var (manifest, tiles, complete, error) = await GrpcTestHelpers.CollectStreamAsync(call);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
throw new Exception($"Happy path returned DeliveryError: {error.Code} — {error.Message}");
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new Exception("Happy path did not emit RouteManifest");
|
||||
}
|
||||
|
||||
if (tiles.Count == 0 && manifest.ToDeliver > 0)
|
||||
{
|
||||
throw new Exception($"Expected tiles in stream (to_deliver={manifest.ToDeliver})");
|
||||
}
|
||||
|
||||
if (complete is null)
|
||||
{
|
||||
throw new Exception("Happy path did not emit DeliveryComplete");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Manifest: candidates={manifest.TotalCandidates}, to_deliver={manifest.ToDeliver}");
|
||||
Console.WriteLine($" ✓ Received {tiles.Count} tile(s), complete.delivered={complete.Delivered}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static async Task RunInvalidRequests(RouteTileDelivery.RouteTileDeliveryClient client, Metadata headers)
|
||||
{
|
||||
RouteTestHelpers.PrintSectionHeader("AC-2/AC-3: Invalid requests return InvalidArgument");
|
||||
|
||||
var emptyRoute = GrpcTestHelpers.BuildValidRequest();
|
||||
emptyRoute.Route!.Waypoints.Clear();
|
||||
emptyRoute.Route.Waypoints.Add(new Waypoint { Lat = Lat1, Lon = Lon1 });
|
||||
|
||||
await GrpcTestHelpers.ExpectInvalidArgumentAsync(async () =>
|
||||
{
|
||||
using var call = client.DeliverRouteTiles(emptyRoute, headers);
|
||||
await GrpcTestHelpers.CollectStreamAsync(call);
|
||||
}, "Single waypoint route");
|
||||
|
||||
var invalidCoord = GrpcTestHelpers.BuildValidRequest();
|
||||
invalidCoord.Route!.Waypoints[0] = new Waypoint { Lat = 91.0, Lon = Lon1 };
|
||||
|
||||
await GrpcTestHelpers.ExpectInvalidArgumentAsync(async () =>
|
||||
{
|
||||
using var call = client.DeliverRouteTiles(invalidCoord, headers);
|
||||
await GrpcTestHelpers.CollectStreamAsync(call);
|
||||
}, "Latitude out of range");
|
||||
|
||||
var invalidZoom = GrpcTestHelpers.BuildValidRequest(zoom: 99);
|
||||
|
||||
await GrpcTestHelpers.ExpectInvalidArgumentAsync(async () =>
|
||||
{
|
||||
using var call = client.DeliverRouteTiles(invalidZoom, headers);
|
||||
await GrpcTestHelpers.CollectStreamAsync(call);
|
||||
}, "Zoom out of allowed range");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static async Task RunBackpressureSafe(RouteTileDelivery.RouteTileDeliveryClient client, Metadata headers)
|
||||
{
|
||||
RouteTestHelpers.PrintSectionHeader("AC-4: Backpressure safe — slow consumer preserves JPEG integrity");
|
||||
|
||||
var request = GrpcTestHelpers.BuildValidRequest(
|
||||
lat1: Lat1, lon1: Lon1, lat2: Lat2, lon2: Lon2,
|
||||
regionSizeMeters: RegionSizeMeters, zoom: Zoom);
|
||||
|
||||
using var call = client.DeliverRouteTiles(request, headers);
|
||||
var (_, tiles, _, error) = await GrpcTestHelpers.CollectStreamAsync(call, delayMsBetweenEvents: 50);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
throw new Exception($"Backpressure test returned DeliveryError: {error.Code}");
|
||||
}
|
||||
|
||||
foreach (var tile in tiles)
|
||||
{
|
||||
if (tile.Jpeg.Length < 2 || tile.Jpeg[0] != 0xFF || tile.Jpeg[1] != 0xD8)
|
||||
{
|
||||
throw new Exception($"Tile ({tile.Z},{tile.X},{tile.Y}) JPEG header invalid after slow read");
|
||||
}
|
||||
|
||||
if (tile.ContentSha256.Length == 32)
|
||||
{
|
||||
var computed = SHA256.HashData(tile.Jpeg.ToByteArray());
|
||||
if (!computed.AsSpan().SequenceEqual(tile.ContentSha256.Span))
|
||||
{
|
||||
throw new Exception($"Tile ({tile.Z},{tile.X},{tile.Y}) content_sha256 mismatch after slow read");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Verified {tiles.Count} tile(s) with intact JPEG + SHA256 under slow consumption");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static async Task RunRestConsistency(
|
||||
HttpClient httpClient,
|
||||
RouteTileDelivery.RouteTileDeliveryClient grpcClient,
|
||||
Metadata headers)
|
||||
{
|
||||
RouteTestHelpers.PrintSectionHeader("AC-3 (AZ-1075): REST and gRPC tile metadata consistency");
|
||||
|
||||
var routeId = Guid.NewGuid();
|
||||
var restRequest = new CreateRouteRequest
|
||||
{
|
||||
Id = routeId,
|
||||
Name = "gRPC consistency probe",
|
||||
RegionSizeMeters = RegionSizeMeters,
|
||||
ZoomLevel = Zoom,
|
||||
RequestMaps = true,
|
||||
Points =
|
||||
[
|
||||
new RoutePointInput { Lat = Lat1, Lon = Lon1 },
|
||||
new RoutePointInput { Lat = Lat2, Lon = Lon2 },
|
||||
],
|
||||
};
|
||||
|
||||
await RouteTestHelpers.CreateRoute(httpClient, restRequest);
|
||||
var restRoute = await RouteTestHelpers.WaitForRouteReady(httpClient, routeId, maxAttempts: 120);
|
||||
|
||||
if (string.IsNullOrEmpty(restRoute.CsvFilePath) || !File.Exists(restRoute.CsvFilePath))
|
||||
{
|
||||
throw new Exception("REST route CSV not available for consistency check");
|
||||
}
|
||||
|
||||
var csvLines = await File.ReadAllLinesAsync(restRoute.CsvFilePath);
|
||||
var restTileKeys = new HashSet<(int Z, int X, int Y)>();
|
||||
foreach (var line in csvLines.Skip(1))
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length < 2
|
||||
|| !double.TryParse(parts[0], out var lat)
|
||||
|| !double.TryParse(parts[1], out var lon))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), Zoom);
|
||||
restTileKeys.Add((Zoom, x, y));
|
||||
}
|
||||
|
||||
var grpcRequest = GrpcTestHelpers.BuildValidRequest(
|
||||
routeId: Guid.NewGuid(),
|
||||
lat1: Lat1, lon1: Lon1, lat2: Lat2, lon2: Lon2,
|
||||
regionSizeMeters: RegionSizeMeters, zoom: Zoom);
|
||||
|
||||
using var call = grpcClient.DeliverRouteTiles(grpcRequest, headers);
|
||||
var (_, grpcTiles, _, error) = await GrpcTestHelpers.CollectStreamAsync(call);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
throw new Exception($"gRPC consistency run failed: {error.Code} — {error.Message}");
|
||||
}
|
||||
|
||||
var grpcKeys = grpcTiles.Select(t => (t.Z, t.X, t.Y)).ToHashSet();
|
||||
var overlap = grpcKeys.Intersect(restTileKeys).Count();
|
||||
|
||||
if (grpcKeys.Count == 0)
|
||||
{
|
||||
throw new Exception("gRPC stream delivered no tiles for consistency comparison");
|
||||
}
|
||||
|
||||
if (overlap == 0)
|
||||
{
|
||||
throw new Exception(
|
||||
$"No overlapping tile keys between REST CSV ({restTileKeys.Count}) and gRPC stream ({grpcKeys.Count})");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ REST CSV tiles: {restTileKeys.Count}, gRPC tiles: {grpcKeys.Count}, overlap: {overlap}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.GrpcContracts\SatelliteProvider.GrpcContracts.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.TestSupport\SatelliteProvider.TestSupport.csproj" />
|
||||
<!-- AZ-503: integration tests need Uuidv5 + TileNamespace so raw SQL seeds
|
||||
can populate tiles.location_hash (NOT NULL after migration 014) using
|
||||
the same algorithm the application uses for new writes. -->
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user