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