Skip to content
Open
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
13 changes: 10 additions & 3 deletions listenarr.api/Features/Images/ImageCandidateLookupWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* (at your option) any later version.
*/

using System.Reflection;

namespace Listenarr.Api.Features.Images
{
Expand Down Expand Up @@ -337,9 +338,15 @@ void AddCandidateUrl(string? url, string source)
}
else
{
// Try dynamic access
dynamic env = metadataEnvelope;
object? mdObj = env.metadata;
// Not `dynamic`: the envelope is an anonymous type, emitted
// internal to Listenarr.Application, so the binder cannot
// see `metadata` from this assembly and throws. Reflection
// is how the inner object is read below, and how
// LibraryMetadataRescanWorkflow reads this same envelope.
object? mdObj = metadataEnvelope.GetType().GetProperty(
"metadata",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?.GetValue(metadataEnvelope);

// If it's already the Audible type, use it
if (mdObj is AudibleBookResponse mdMeta)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,107 @@ public async Task GetImage_FallsBackToGetMetadataAsync_WhenAudibleNull_AndDownlo
// Best-effort test cleanup; ignore cleanup failures.
}
}

// The test above returns the AudibleBookResponse directly, and its own comment says that is
// to avoid "anonymous envelope issues". But an anonymous envelope is what the production
// service actually returns, so dodging it leaves the fallback covered only in the one shape
// that never exercises the unwrap. This covers the other one.
//
// The anonymous type here is emitted internal to this test assembly, and the workflow that
// unwraps it lives in Listenarr.Api, so the accessibility relationship is the same one that
// exists in production between Listenarr.Application and Listenarr.Api.
[Fact]
public async Task GetImage_FallbackEnvelopeIsAnonymousType_StillFindsTheImageUrl()
{
var identifier = "BTESTASIN";
var relativePath = $"config/cache/images/temp/{identifier}.jpg";
var imageUrl = "https://audnexus.covers/anonymous-envelope.jpg";

var mockImageCache = new Mock<IImageCacheService>();
mockImageCache
.Setup(m => m.DownloadAndCacheImageAsync(imageUrl, identifier))
.ReturnsAsync(relativePath);
mockImageCache
.SetupSequence(m => m.GetCachedImagePathAsync(identifier))
.ReturnsAsync((string?)null)
.ReturnsAsync(relativePath);

using var httpClientForAudible = new System.Net.Http.HttpClient();
var audibleMock = new Mock<AudibleService>(
httpClientForAudible,
Mock.Of<ILogger<AudibleService>>());
audibleMock
.Setup(a => a.GetBookMetadataAsync(
identifier,
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<string?>()))
.ReturnsAsync((AudibleBookResponse?)null);

var mockMetadata = new Mock<IAudiobookMetadataService>();
mockMetadata
.Setup(m => m.GetAudibleMetadataAsync(
identifier,
It.IsAny<string>(),
It.IsAny<bool>()))
.ReturnsAsync((AudibleBookResponse?)null);
// The same envelope shape AudiobookMetadataService returns on its success path.
mockMetadata
.Setup(m => m.GetMetadataAsync(
identifier,
It.IsAny<string>(),
It.IsAny<bool>()))
.ReturnsAsync((object)new
{
metadata = new AudibleBookResponse { ImageUrl = imageUrl },
source = "Audnexus",
sourceUrl = "https://api.audnex.us"
});

var tempRoot = Path.Join(
Path.GetTempPath(),
"listenarr_test_contentroot_anonymous_envelope");
Directory.CreateDirectory(Path.Join(tempRoot, "config", "cache", "images", "temp"));
var fullPath = Path.Join(tempRoot, relativePath);
File.WriteAllText(fullPath, "fake image data");

var mockPathService = new Mock<IApplicationPathService>();
mockPathService.SetupGet(p => p.ContentRootPath).Returns(tempRoot);

var controller = new ImagesController(
mockImageCache.Object,
mockMetadata.Object,
audibleMock.Object,
Mock.Of<IAudnexusService>(),
Mock.Of<IAudiobookRepository>(),
Mock.Of<ILogger<ImagesController>>(),
mockPathService.Object,
new LocalFileSystem());
controller.ControllerContext = new ControllerContext
{
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext()
};

var result = await controller.GetImage(identifier);

// The URL has to be recovered from inside the envelope for the downloader to be called
// at all. Reading it with `dynamic` throws RuntimeBinderException, which is not in the
// recoverable list, so it escapes the filtered catch and the controller returns 500
// having never reached the download.
mockImageCache.Verify(
m => m.DownloadAndCacheImageAsync(imageUrl, identifier),
Times.Once);
Assert.IsNotType<StatusCodeResult>(result);

try
{
File.Delete(fullPath);
Directory.Delete(Path.Join(tempRoot, "config", "cache", "images", "temp"), true);
}
catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException)
{
// Best-effort test cleanup; ignore cleanup failures.
}
}
}
}