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..3cd8e47a
--- /dev/null
+++ b/tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs
@@ -0,0 +1,164 @@
+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);
+ }
+
+ [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)]
+ [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()
);
}