diff --git a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs index fd486f8..5bf4729 100644 --- a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs +++ b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs @@ -16,5 +16,6 @@ public class CreateRouteRequest public Geofences? Geofences { get; set; } public bool RequestMaps { get; set; } = false; + public bool CreateTilesZip { get; set; } = false; } diff --git a/SatelliteProvider.Common/DTO/RouteResponse.cs b/SatelliteProvider.Common/DTO/RouteResponse.cs index d68b2b1..f4160d4 100644 --- a/SatelliteProvider.Common/DTO/RouteResponse.cs +++ b/SatelliteProvider.Common/DTO/RouteResponse.cs @@ -15,6 +15,7 @@ public class RouteResponse public string? CsvFilePath { get; set; } public string? SummaryFilePath { get; set; } public string? StitchedImagePath { get; set; } + public string? TilesZipPath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.DataAccess/Migrations/010_AddTilesZipToRoutes.sql b/SatelliteProvider.DataAccess/Migrations/010_AddTilesZipToRoutes.sql new file mode 100644 index 0000000..de61c0c --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/010_AddTilesZipToRoutes.sql @@ -0,0 +1,4 @@ +ALTER TABLE routes +ADD COLUMN create_tiles_zip BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN tiles_zip_path VARCHAR(500); + diff --git a/SatelliteProvider.DataAccess/Models/RouteEntity.cs b/SatelliteProvider.DataAccess/Models/RouteEntity.cs index 9960dab..c48c1e0 100644 --- a/SatelliteProvider.DataAccess/Models/RouteEntity.cs +++ b/SatelliteProvider.DataAccess/Models/RouteEntity.cs @@ -11,9 +11,11 @@ public class RouteEntity public int TotalPoints { get; set; } public bool RequestMaps { get; set; } public bool MapsReady { get; set; } + public bool CreateTilesZip { get; set; } public string? CsvFilePath { get; set; } public string? SummaryFilePath { get; set; } public string? StitchedImagePath { get; set; } + public string? TilesZipPath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs index 71d0ee6..3f78b7c 100644 --- a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs @@ -23,8 +23,10 @@ public class RouteRepository : IRouteRepository SELECT 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, csv_file_path as CsvFilePath, + 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 FROM routes WHERE id = @Id"; @@ -53,12 +55,12 @@ public class RouteRepository : IRouteRepository const string sql = @" INSERT INTO routes (id, name, description, region_size_meters, zoom_level, total_distance_meters, total_points, request_maps, maps_ready, - csv_file_path, summary_file_path, stitched_image_path, - created_at, updated_at) + create_tiles_zip, csv_file_path, summary_file_path, stitched_image_path, + tiles_zip_path, created_at, updated_at) VALUES (@Id, @Name, @Description, @RegionSizeMeters, @ZoomLevel, @TotalDistanceMeters, @TotalPoints, @RequestMaps, @MapsReady, - @CsvFilePath, @SummaryFilePath, @StitchedImagePath, - @CreatedAt, @UpdatedAt) + @CreateTilesZip, @CsvFilePath, @SummaryFilePath, @StitchedImagePath, + @TilesZipPath, @CreatedAt, @UpdatedAt) RETURNING id"; return await connection.ExecuteScalarAsync(sql, route); @@ -90,9 +92,11 @@ public class RouteRepository : IRouteRepository total_points = @TotalPoints, request_maps = @RequestMaps, maps_ready = @MapsReady, + create_tiles_zip = @CreateTilesZip, csv_file_path = @CsvFilePath, summary_file_path = @SummaryFilePath, stitched_image_path = @StitchedImagePath, + tiles_zip_path = @TilesZipPath, updated_at = @UpdatedAt WHERE id = @Id"; @@ -146,8 +150,10 @@ public class RouteRepository : IRouteRepository SELECT 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, csv_file_path as CsvFilePath, + 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 FROM routes WHERE request_maps = true AND maps_ready = false"; diff --git a/SatelliteProvider.IntegrationTests/Models.cs b/SatelliteProvider.IntegrationTests/Models.cs index 0ac9ac9..76eb726 100644 --- a/SatelliteProvider.IntegrationTests/Models.cs +++ b/SatelliteProvider.IntegrationTests/Models.cs @@ -78,6 +78,7 @@ public class CreateRouteRequest [System.Text.Json.Serialization.JsonPropertyName("geofences")] public GeofencesInput? Geofences { get; set; } public bool RequestMaps { get; set; } = false; + public bool CreateTilesZip { get; set; } = false; } public class RoutePointModel @@ -105,6 +106,7 @@ public class RouteResponseModel public string? CsvFilePath { get; set; } public string? SummaryFilePath { get; set; } public string? StitchedImagePath { get; set; } + public string? TilesZipPath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index fa015d8..06f9ee4 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -36,6 +36,8 @@ class Program await RouteTests.RunRouteWithRegionProcessingAndStitching(httpClient); + await RouteTests.RunRouteWithTilesZipTest(httpClient); + await RouteTests.RunComplexRouteWithStitching(httpClient); await RouteTests.RunComplexRouteWithStitchingAndGeofences(httpClient); await RouteTests.RunExtendedRouteEast(httpClient); diff --git a/SatelliteProvider.IntegrationTests/RouteTests.cs b/SatelliteProvider.IntegrationTests/RouteTests.cs index 901c173..5feb252 100644 --- a/SatelliteProvider.IntegrationTests/RouteTests.cs +++ b/SatelliteProvider.IntegrationTests/RouteTests.cs @@ -989,5 +989,220 @@ public static class RouteTests Console.WriteLine("✓ Extended Route with 20 Points (10km East) Test: PASSED"); } + + public static async Task RunRouteWithTilesZipTest(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("Test: Route with Tiles ZIP File Creation"); + Console.WriteLine("========================================="); + Console.WriteLine(); + + var routeId = Guid.NewGuid(); + var request = new CreateRouteRequest + { + Id = routeId, + Name = "Route with Tiles ZIP", + Description = "Test route with tiles zip file creation", + RegionSizeMeters = 500.0, + ZoomLevel = 18, + RequestMaps = true, + CreateTilesZip = true, + Points = new List + { + new() { Lat = 48.276067180586544, Lon = 37.38445758819581 }, + new() { Lat = 48.27074009522731, Lon = 37.374029159545906 } + } + }; + + Console.WriteLine($"Creating route with 2 points:"); + Console.WriteLine($" Start: ({request.Points[0].Lat}, {request.Points[0].Lon})"); + Console.WriteLine($" End: ({request.Points[1].Lat}, {request.Points[1].Lon})"); + Console.WriteLine($" Region Size: {request.RegionSizeMeters}m"); + Console.WriteLine($" Zoom Level: {request.ZoomLevel}"); + Console.WriteLine($" Request Maps: {request.RequestMaps}"); + Console.WriteLine($" Create Tiles ZIP: {request.CreateTilesZip}"); + Console.WriteLine(); + + var response = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new Exception($"API returned error status {response.StatusCode}: {errorContent}"); + } + + var route = await response.Content.ReadFromJsonAsync(JsonOptions); + + if (route == null) + { + throw new Exception("No route data returned from API"); + } + + Console.WriteLine("Route Details:"); + Console.WriteLine($" ID: {route.Id}"); + Console.WriteLine($" Name: {route.Name}"); + Console.WriteLine($" Total Points: {route.TotalPoints}"); + Console.WriteLine($" Total Distance: {route.TotalDistanceMeters:F2}m"); + Console.WriteLine($" Request Maps: {route.RequestMaps}"); + Console.WriteLine($" Maps Ready: {route.MapsReady}"); + Console.WriteLine(); + + if (!route.RequestMaps) + { + throw new Exception("Expected RequestMaps to be true"); + } + + if (route.MapsReady) + { + throw new Exception("Expected MapsReady to be false initially"); + } + + Console.WriteLine("Step 2: Waiting for route maps and zip file to be ready"); + Console.WriteLine(" (Service is processing regions SEQUENTIALLY to avoid API throttling)"); + Console.WriteLine(); + + RouteResponseModel? finalRoute = null; + int maxAttempts = 180; + int pollInterval = 3000; + + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + await Task.Delay(pollInterval); + + var getResponse = await httpClient.GetAsync($"/api/satellite/route/{routeId}"); + + if (!getResponse.IsSuccessStatusCode) + { + throw new Exception($"Failed to get route status: {getResponse.StatusCode}"); + } + + var currentRoute = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + + if (currentRoute == null) + { + throw new Exception("No route returned"); + } + + if (currentRoute.MapsReady) + { + finalRoute = currentRoute; + Console.WriteLine($"✓ Route maps ready in approximately {attempt * pollInterval / 1000}s"); + break; + } + + if (attempt % 5 == 0) + { + Console.WriteLine($" Waiting... (attempt {attempt + 1}/{maxAttempts})"); + } + + if (attempt == maxAttempts - 1) + { + throw new Exception($"Timeout: Route maps did not become ready in {maxAttempts * pollInterval / 1000}s"); + } + } + + if (finalRoute == null) + { + throw new Exception("Final route is null"); + } + + Console.WriteLine(); + + Console.WriteLine("Step 3: Verifying generated files including ZIP"); + + if (string.IsNullOrEmpty(finalRoute.CsvFilePath)) + { + throw new Exception("CSV file path is null or empty"); + } + + if (string.IsNullOrEmpty(finalRoute.SummaryFilePath)) + { + throw new Exception("Summary file path is null or empty"); + } + + if (string.IsNullOrEmpty(finalRoute.StitchedImagePath)) + { + throw new Exception("Stitched image path is null or empty"); + } + + if (string.IsNullOrEmpty(finalRoute.TilesZipPath)) + { + throw new Exception("Tiles ZIP file path is null or empty"); + } + + if (!File.Exists(finalRoute.CsvFilePath)) + { + throw new Exception($"CSV file not found: {finalRoute.CsvFilePath}"); + } + + if (!File.Exists(finalRoute.SummaryFilePath)) + { + throw new Exception($"Summary file not found: {finalRoute.SummaryFilePath}"); + } + + if (!File.Exists(finalRoute.StitchedImagePath)) + { + throw new Exception($"Stitched image not found: {finalRoute.StitchedImagePath}"); + } + + if (!File.Exists(finalRoute.TilesZipPath)) + { + throw new Exception($"Tiles ZIP file not found: {finalRoute.TilesZipPath}"); + } + + var csvLines = await File.ReadAllLinesAsync(finalRoute.CsvFilePath); + var uniqueTileCount = csvLines.Length - 1; + + var stitchedInfo = new FileInfo(finalRoute.StitchedImagePath); + var zipInfo = new FileInfo(finalRoute.TilesZipPath); + + Console.WriteLine("Files Generated:"); + Console.WriteLine($" ✓ CSV: {Path.GetFileName(finalRoute.CsvFilePath)} ({uniqueTileCount} tiles)"); + Console.WriteLine($" ✓ Summary: {Path.GetFileName(finalRoute.SummaryFilePath)}"); + Console.WriteLine($" ✓ Stitched Map: {Path.GetFileName(finalRoute.StitchedImagePath)} ({stitchedInfo.Length / 1024:F2} KB)"); + Console.WriteLine($" ✓ Tiles ZIP: {Path.GetFileName(finalRoute.TilesZipPath)} ({zipInfo.Length / 1024:F2} KB)"); + Console.WriteLine(); + + Console.WriteLine("Verifying ZIP file contents:"); + using (var zipArchive = System.IO.Compression.ZipFile.OpenRead(finalRoute.TilesZipPath)) + { + Console.WriteLine($" ZIP contains {zipArchive.Entries.Count} files"); + + if (zipArchive.Entries.Count == 0) + { + throw new Exception("ZIP file is empty"); + } + + if (zipArchive.Entries.Count != uniqueTileCount) + { + throw new Exception($"ZIP contains {zipArchive.Entries.Count} files but CSV lists {uniqueTileCount} tiles"); + } + + var firstEntry = zipArchive.Entries[0]; + Console.WriteLine($" First entry: {firstEntry.Name} ({firstEntry.Length} bytes)"); + + if (firstEntry.Length == 0) + { + throw new Exception("First entry in ZIP is empty"); + } + } + + Console.WriteLine(); + Console.WriteLine("Route Summary:"); + Console.WriteLine($" Route ID: {finalRoute.Id}"); + Console.WriteLine($" Route Points: {finalRoute.TotalPoints}"); + Console.WriteLine($" Distance: {finalRoute.TotalDistanceMeters:F2}m"); + Console.WriteLine($" Unique Tiles: {uniqueTileCount}"); + Console.WriteLine($" ZIP File Size: {zipInfo.Length / 1024:F2} KB"); + Console.WriteLine($" Maps Ready: {finalRoute.MapsReady}"); + Console.WriteLine(); + + if (zipInfo.Length < 1024) + { + throw new Exception($"ZIP file seems too small: {zipInfo.Length} bytes"); + } + + Console.WriteLine("✓ Route with Tiles ZIP File Test: PASSED"); + } } diff --git a/SatelliteProvider.Services/RouteProcessingService.cs b/SatelliteProvider.Services/RouteProcessingService.cs index 81d4c2a..fe2dff9 100644 --- a/SatelliteProvider.Services/RouteProcessingService.cs +++ b/SatelliteProvider.Services/RouteProcessingService.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -307,13 +308,21 @@ public class RouteProcessingService : BackgroundService await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken); } + string? tilesZipPath = null; + if (route.CreateTilesZip) + { + tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip"); + await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken); + } + var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); - await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, cancellationToken); + await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken); route.MapsReady = true; route.CsvFilePath = csvPath; route.SummaryFilePath = summaryPath; route.StitchedImagePath = stitchedImagePath; + route.TilesZipPath = tilesZipPath; route.UpdatedAt = DateTime.UtcNow; await _routeRepository.UpdateRouteAsync(route); @@ -400,6 +409,7 @@ public class RouteProcessingService : BackgroundService int uniqueTiles, int totalTilesFromRegions, int duplicateTiles, + string? tilesZipPath, CancellationToken cancellationToken) { var summary = new System.Text.StringBuilder(); @@ -428,6 +438,10 @@ public class RouteProcessingService : BackgroundService { summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg"); } + if (tilesZipPath != null) + { + summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip"); + } summary.AppendLine(); summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); @@ -670,6 +684,52 @@ public class RouteProcessingService : BackgroundService } } } + + private Task CreateTilesZipAsync( + string zipFilePath, + IEnumerable tiles, + CancellationToken cancellationToken) + { + return Task.Run(() => + { + if (File.Exists(zipFilePath)) + { + File.Delete(zipFilePath); + } + + using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create); + int addedFiles = 0; + int missingFiles = 0; + + foreach (var tile in tiles) + { + if (cancellationToken.IsCancellationRequested) + break; + + if (File.Exists(tile.FilePath)) + { + try + { + var fileName = Path.GetFileName(tile.FilePath); + zipArchive.CreateEntryFromFile(tile.FilePath, fileName, CompressionLevel.Optimal); + addedFiles++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath); + } + } + else + { + _logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath); + missingFiles++; + } + } + + _logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)", + zipFilePath, addedFiles, missingFiles); + }, cancellationToken); + } } public class TileInfo diff --git a/SatelliteProvider.Services/RouteService.cs b/SatelliteProvider.Services/RouteService.cs index 71c5f84..7edfc79 100644 --- a/SatelliteProvider.Services/RouteService.cs +++ b/SatelliteProvider.Services/RouteService.cs @@ -117,6 +117,7 @@ public class RouteService : IRouteService TotalDistanceMeters = totalDistance, TotalPoints = allPoints.Count, RequestMaps = request.RequestMaps, + CreateTilesZip = request.CreateTilesZip, MapsReady = false, CreatedAt = now, UpdatedAt = now @@ -203,6 +204,7 @@ public class RouteService : IRouteService CsvFilePath = routeEntity.CsvFilePath, SummaryFilePath = routeEntity.SummaryFilePath, StitchedImagePath = routeEntity.StitchedImagePath, + TilesZipPath = routeEntity.TilesZipPath, CreatedAt = routeEntity.CreatedAt, UpdatedAt = routeEntity.UpdatedAt }; @@ -241,6 +243,7 @@ public class RouteService : IRouteService CsvFilePath = route.CsvFilePath, SummaryFilePath = route.SummaryFilePath, StitchedImagePath = route.StitchedImagePath, + TilesZipPath = route.TilesZipPath, CreatedAt = route.CreatedAt, UpdatedAt = route.UpdatedAt };