Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Services/TripExportCoverSnapshotBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace Wayfarer.Services;

/// <summary>
/// Builds PDF cover snapshot data URIs through the shared image proxy pipeline.
/// </summary>
internal static class TripExportCoverSnapshotBuilder
{
/// <summary>
/// Fetches the cover image through the proxy service and returns a complete data URI.
/// </summary>
public static async Task<string?> 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;
}
}
}
20 changes: 9 additions & 11 deletions Services/TripExportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,7 @@ public class TripExportService : ITripExportService
readonly ILogger<TripExportService> _logger;
readonly IConfiguration _configuration;
readonly SseService _sseService;
readonly IImageProxyService _imageProxyService;
readonly string _chromeCachePath;
private static readonly CultureInfo CI = CultureInfo.InvariantCulture;

Expand All @@ -40,7 +42,8 @@ public TripExportService(
IRazorViewRenderer razor,
ILogger<TripExportService> logger,
IConfiguration configuration,
SseService sseService)
SseService sseService,
IImageProxyService imageProxyService)
{
_db = dbContext;
_snap = mapSnapshot;
Expand All @@ -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";
Expand Down Expand Up @@ -354,20 +358,13 @@ await _sseService.BroadcastAsync(progressChannel,

/* 3 ── snapshots dictionary --------------------------------------- */
var snap = new Dictionary<string, byte[]>();
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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
}
164 changes: 164 additions & 0 deletions tests/Wayfarer.Tests/Services/TripExportCoverSnapshotBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using Moq;
using Wayfarer.Services;
using Xunit;

namespace Wayfarer.Tests.Services;

/// <summary>
/// Tests PDF cover snapshot conversion without invoking full Playwright PDF generation.
/// </summary>
public class TripExportCoverSnapshotBuilderTests
{
[Fact]
public async Task BuildDataUriAsync_UsesProxyBytesAndContentType()
{
// Arrange
var coverBytes = new byte[] { 1, 2, 3 };
var imageProxy = new Mock<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.Is<ImageProxyRequest>(r => r.Url == "https://example.com/cover.jpg"),
true,
It.IsAny<CancellationToken>()))
.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<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.Is<ImageProxyRequest>(r => r.Url == "https://example.com/cover.jpg"),
true,
It.IsAny<CancellationToken>()))
.Callback<ImageProxyRequest, bool, CancellationToken>((_, _, 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<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.IsAny<ImageProxyRequest>(),
true,
It.Is<CancellationToken>(ct => ct == cancellation.Token)))
.Returns(Task.FromCanceled<ImageProxyResult>(cancellation.Token));

// Act and assert
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
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<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.IsAny<ImageProxyRequest>(),
true,
It.IsAny<CancellationToken>()))
.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<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.IsAny<ImageProxyRequest>(),
true,
It.IsAny<CancellationToken>()))
.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<IImageProxyService>();
imageProxy
.Setup(s => s.GetOrFetchAsync(
It.IsAny<ImageProxyRequest>(),
true,
It.IsAny<CancellationToken>()))
.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);
}
}
8 changes: 4 additions & 4 deletions tests/Wayfarer.Tests/Services/TripExportServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ namespace Wayfarer.Tests.Services;

/// <summary>
/// Tests for <see cref="TripExportService"/> 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).
/// </summary>
public class TripExportServiceTests : TestBase
{
/// <summary>
/// Creates a TripExportService with minimal mocked dependencies for KML-only testing.
/// Creates a TripExportService with minimal mocked dependencies for scoped export testing.
/// </summary>
private TripExportService CreateService(ApplicationDbContext db)
private TripExportService CreateService(ApplicationDbContext db, IImageProxyService? imageProxyService = null)
{
var mockConfig = new Mock<IConfiguration>();
mockConfig.Setup(c => c["CacheSettings:ChromeCacheDirectory"]).Returns("TestCache");
Expand All @@ -34,7 +34,7 @@ private TripExportService CreateService(ApplicationDbContext db)
null!, // IRazorViewRenderer - not needed for KML
NullLogger<TripExportService>.Instance,
mockConfig.Object,
null! // SseService - not needed for KML
null!, imageProxyService ?? Mock.Of<IImageProxyService>()
);
}

Expand Down
Loading