From ad1770bec7a9df45436e17686b3268941be6b47a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 21 May 2026 08:24:24 +0300 Subject: [PATCH 1/3] WIP: start pdf cover proxy issue 319 (checkpoint) From b2588135302439252d9455dda12f5062a3f6b1b8 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 21 May 2026 08:30:46 +0300 Subject: [PATCH 2/3] fix(exports): proxy PDF cover snapshots --- Services/TripExportCoverSnapshotBuilder.cs | 44 +++++++ Services/TripExportService.cs | 20 ++-- .../TripExportCoverSnapshotBuilderTests.cs | 113 ++++++++++++++++++ .../Services/TripExportServiceTests.cs | 8 +- 4 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 Services/TripExportCoverSnapshotBuilder.cs create mode 100644 tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs diff --git a/Services/TripExportCoverSnapshotBuilder.cs b/Services/TripExportCoverSnapshotBuilder.cs new file mode 100644 index 00000000..45100170 --- /dev/null +++ b/Services/TripExportCoverSnapshotBuilder.cs @@ -0,0 +1,44 @@ +namespace Wayfarer.Services; + +/// +/// Builds PDF cover snapshot data URIs through the shared image proxy pipeline. +/// +internal static class TripExportCoverSnapshotBuilder +{ + /// + /// Fetches the cover image through the proxy service and returns a complete data URI. + /// + public static async Task BuildDataUriAsync( + IImageProxyService imageProxyService, + string? coverImageUrl, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(coverImageUrl)) + { + return null; + } + + try + { + var result = await imageProxyService.GetOrFetchAsync( + new ImageProxyRequest(coverImageUrl), + allowOriginFetch: true, + cancellationToken); + + if (!result.HasBytes) + { + return null; + } + + return $"data:{result.ContentType};base64,{Convert.ToBase64String(result.Bytes!)}"; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return null; + } + } +} diff --git a/Services/TripExportService.cs b/Services/TripExportService.cs index d1f38c32..e057bfe2 100644 --- a/Services/TripExportService.cs +++ b/Services/TripExportService.cs @@ -10,6 +10,7 @@ using NetTopologySuite.Geometries; using Wayfarer.Models; using Wayfarer.Models.ViewModels; +using Wayfarer.Services; using static Wayfarer.Parsers.KmlMappings; namespace Wayfarer.Parsers @@ -29,6 +30,7 @@ public class TripExportService : ITripExportService readonly ILogger _logger; readonly IConfiguration _configuration; readonly SseService _sseService; + readonly IImageProxyService _imageProxyService; readonly string _chromeCachePath; private static readonly CultureInfo CI = CultureInfo.InvariantCulture; @@ -40,7 +42,8 @@ public TripExportService( IRazorViewRenderer razor, ILogger logger, IConfiguration configuration, - SseService sseService) + SseService sseService, + IImageProxyService imageProxyService) { _db = dbContext; _snap = mapSnapshot; @@ -50,6 +53,7 @@ public TripExportService( _logger = logger; _configuration = configuration; _sseService = sseService; + _imageProxyService = imageProxyService; // Get Chrome cache directory from configuration (defaults to ChromeCache if not specified) _chromeCachePath = configuration["CacheSettings:ChromeCacheDirectory"] ?? "ChromeCache"; @@ -354,20 +358,13 @@ await _sseService.BroadcastAsync(progressChannel, /* 3 ── snapshots dictionary --------------------------------------- */ var snap = new Dictionary(); + string? coverDataUri = null; // cover photo (download once) if (!string.IsNullOrWhiteSpace(trip.CoverImageUrl)) { await ReportProgress("📷 Downloading cover photo..."); - try - { - using var http = new HttpClient(); - snap["cover"] = await http.GetByteArrayAsync(trip.CoverImageUrl, cancellationToken); - } - catch - { - /* ignore – cover simply omitted on failure */ - } + coverDataUri = await TripExportCoverSnapshotBuilder.BuildDataUriAsync(_imageProxyService, trip.CoverImageUrl, cancellationToken); } cancellationToken.ThrowIfCancellationRequested(); @@ -461,6 +458,7 @@ string ToDataUri(byte[] png) => // convert snapshots dictionary to data-URI strings var snapUris = snap.ToDictionary(kvp => kvp.Key, kvp => ToDataUri(kvp.Value)); + if (coverDataUri is not null) snapUris["cover"] = coverDataUri; // build view-model var vm = new TripPrintViewModel @@ -615,4 +613,4 @@ string BuildMapUrl(double lat, double lon, int zoom, bool pub, Guid id, string? return uri + "&print=1"; // ?print=1 hides sidebar for snapshots } } -} \ No newline at end of file +} diff --git a/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs b/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs new file mode 100644 index 00000000..e974bbf9 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs @@ -0,0 +1,113 @@ +using Moq; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// +/// Tests PDF cover snapshot conversion without invoking full Playwright PDF generation. +/// +public class TripExportCoverSnapshotBuilderTests +{ + [Fact] + public async Task BuildDataUriAsync_UsesProxyBytesAndContentType() + { + // Arrange + var coverBytes = new byte[] { 1, 2, 3 }; + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.Is(r => r.Url == "https://example.com/cover.jpg"), + true, + It.IsAny())) + .ReturnsAsync(new ImageProxyResult( + ImageProxyResultStatus.Fetched, + "cover-key", + coverBytes, + "image/jpeg")); + + // Act + var dataUri = await TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.jpg"); + + // Assert + Assert.Equal("data:image/jpeg;base64,AQID", dataUri); + } + + [Theory] + [InlineData(ImageProxyResultStatus.BadRequest, null, null)] + [InlineData(ImageProxyResultStatus.NotFound, null, null)] + [InlineData(ImageProxyResultStatus.TooLarge, null, null)] + [InlineData(ImageProxyResultStatus.Failed, null, null)] + [InlineData(ImageProxyResultStatus.Fetched, new byte[0], "image/jpeg")] + [InlineData(ImageProxyResultStatus.Fetched, new byte[] { 1 }, null)] + public async Task BuildDataUriAsync_OmitsCover_WhenProxyHasNoUsableBytes( + ImageProxyResultStatus status, + byte[]? bytes, + string? contentType) + { + // Arrange + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.IsAny(), + true, + It.IsAny())) + .ReturnsAsync(new ImageProxyResult(status, "cover-key", bytes, contentType)); + + // Act + var dataUri = await TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.jpg"); + + // Assert + Assert.Null(dataUri); + } + + [Fact] + public async Task BuildDataUriAsync_OmitsCover_WhenProxyThrows() + { + // Arrange + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.IsAny(), + true, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Origin fetch failed")); + + // Act + var dataUri = await TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.jpg"); + + // Assert + Assert.Null(dataUri); + } + + [Fact] + public async Task BuildDataUriAsync_PreservesNonPngContentType() + { + // Arrange + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.IsAny(), + true, + It.IsAny())) + .ReturnsAsync(new ImageProxyResult( + ImageProxyResultStatus.Fetched, + "cover-key", + new byte[] { 4, 5, 6 }, + "image/webp")); + + // Act + var dataUri = await TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.webp"); + + // Assert + Assert.Equal("data:image/webp;base64,BAUG", dataUri); + } +} diff --git a/tests/Wayfarer.Tests/Services/TripExportServiceTests.cs b/tests/Wayfarer.Tests/Services/TripExportServiceTests.cs index b0eb5552..66a4e8d2 100644 --- a/tests/Wayfarer.Tests/Services/TripExportServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TripExportServiceTests.cs @@ -14,14 +14,14 @@ namespace Wayfarer.Tests.Services; /// /// Tests for covering KML export operations. -/// Note: PDF generation tests are skipped due to complex external dependencies (Playwright, HttpContext). +/// Note: Full PDF generation tests are skipped due to complex external dependencies (Playwright, HttpContext). /// public class TripExportServiceTests : TestBase { /// - /// Creates a TripExportService with minimal mocked dependencies for KML-only testing. + /// Creates a TripExportService with minimal mocked dependencies for scoped export testing. /// - private TripExportService CreateService(ApplicationDbContext db) + private TripExportService CreateService(ApplicationDbContext db, IImageProxyService? imageProxyService = null) { var mockConfig = new Mock(); mockConfig.Setup(c => c["CacheSettings:ChromeCacheDirectory"]).Returns("TestCache"); @@ -34,7 +34,7 @@ private TripExportService CreateService(ApplicationDbContext db) null!, // IRazorViewRenderer - not needed for KML NullLogger.Instance, mockConfig.Object, - null! // SseService - not needed for KML + null!, imageProxyService ?? Mock.Of() ); } From 538cd91625a76f6841249c8cd7f851bc1369f4b6 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Thu, 21 May 2026 08:42:03 +0300 Subject: [PATCH 3/3] test(exports): cover PDF cover proxy cancellation --- .../TripExportCoverSnapshotBuilderTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs b/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs index e974bbf9..3cd8e47a 100644 --- a/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs +++ b/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs @@ -35,6 +35,57 @@ public async Task BuildDataUriAsync_UsesProxyBytesAndContentType() Assert.Equal("data:image/jpeg;base64,AQID", dataUri); } + [Fact] + public async Task BuildDataUriAsync_ForwardsCancellationTokenToProxy() + { + // Arrange + using var cancellation = new CancellationTokenSource(); + var forwardedToken = CancellationToken.None; + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.Is(r => r.Url == "https://example.com/cover.jpg"), + true, + It.IsAny())) + .Callback((_, _, ct) => forwardedToken = ct) + .ReturnsAsync(new ImageProxyResult( + ImageProxyResultStatus.Fetched, + "cover-key", + new byte[] { 1 }, + "image/jpeg")); + + // Act + await TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.jpg", + cancellation.Token); + + // Assert + Assert.Equal(cancellation.Token, forwardedToken); + } + + [Fact] + public async Task BuildDataUriAsync_RethrowsProxyCancellation() + { + // Arrange + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var imageProxy = new Mock(); + imageProxy + .Setup(s => s.GetOrFetchAsync( + It.IsAny(), + true, + It.Is(ct => ct == cancellation.Token))) + .Returns(Task.FromCanceled(cancellation.Token)); + + // Act and assert + await Assert.ThrowsAnyAsync(() => + TripExportCoverSnapshotBuilder.BuildDataUriAsync( + imageProxy.Object, + "https://example.com/cover.jpg", + cancellation.Token)); + } + [Theory] [InlineData(ImageProxyResultStatus.BadRequest, null, null)] [InlineData(ImageProxyResultStatus.NotFound, null, null)]