diff --git a/listenarr.api/Features/Search/SearchResponseMapper.cs b/listenarr.api/Features/Search/SearchResponseMapper.cs index 7c116f0483..1cffc02cf5 100644 --- a/listenarr.api/Features/Search/SearchResponseMapper.cs +++ b/listenarr.api/Features/Search/SearchResponseMapper.cs @@ -253,7 +253,12 @@ public async Task MapMetadataResultToAudibleAsync( if (!string.IsNullOrWhiteSpace(md?.Author)) fallbackAuthors.Add(new { asin = (string?)null, name = md.Author, region = region, regions = new[] { region }, image = (string?)null, updatedAt = (string?)null }); var fallbackSeries = new List(); - if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = md.Series, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); + // A MetadataSearchResult carries the series as a name only, so there is no series + // identifier to report here. The author fallback three lines above says so with a + // null asin; this one used to repeat the name into the field instead, and clients + // that store what they are handed then wrote a series name into a column whose + // whole value is that it is not a name. + if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = (string?)null, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); return new { diff --git a/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs b/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs new file mode 100644 index 0000000000..ea8b9e7c23 --- /dev/null +++ b/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs @@ -0,0 +1,115 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Api.Features.Search +{ + /// + /// MapMetadataResultToAudibleAsync reshapes a MetadataSearchResult into the Audible response + /// shape. When the Audible product lookup cannot be made, it falls back to the fields the + /// metadata result already carries. A MetadataSearchResult holds the series as a name only, + /// so the fallback has no series identifier to report and must say so with a null rather + /// than repeating the name into the asin field. Clients that store what they are handed + /// otherwise write a series name into a series identifier column. + /// + [Trait("Name", "SearchResponseMapperSeriesFallbackTests")] + [Trait("Category", "Api")] + public class SearchResponseMapperSeriesFallbackTests : BaseTests + { + private static SearchResponseMapper MapperWithNoAudibleLookup() + { + var metadataService = new Mock(); + metadataService + .Setup(s => s.GetAudibleMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((AudibleBookResponse?)null); + + return new SearchResponseMapper( + metadataService.Object, + NullLogger.Instance); + } + + private static async Task MapAsync(MetadataSearchResult md) + { + var mapped = await MapperWithNoAudibleLookup() + .MapMetadataResultToAudibleAsync(md, "us", new DefaultHttpContext()); + + return JsonSerializer.SerializeToElement(mapped); + } + + private static MetadataSearchResult SheAndAllan() => new() + { + Asin = "B00CQ5WAXW", + Title = "She and Allan", + Author = "H. Rider Haggard", + Series = "Ayesha", + SeriesNumber = "0", + }; + + [Fact] + public async Task SeriesFallback_ReportsNoSeriesIdentifier() + { + var element = await MapAsync(SheAndAllan()); + + var series = Assert.Single(element.GetProperty("series").EnumerateArray().ToList()); + + Assert.Equal(JsonValueKind.Null, series.GetProperty("asin").ValueKind); + } + + [Fact] + public async Task SeriesFallback_StillCarriesTheNameAndPosition() + { + var element = await MapAsync(SheAndAllan()); + + var series = Assert.Single(element.GetProperty("series").EnumerateArray().ToList()); + + Assert.Equal("Ayesha", series.GetProperty("name").GetString()); + Assert.Equal("0", series.GetProperty("position").GetString()); + } + + /// + /// The author fallback immediately above already reports a null asin for the same reason. + /// Pinned here so the two stay consistent: if somebody ever fills one of them in from a + /// name, this test says which convention the file follows. + /// + [Fact] + public async Task AuthorFallback_AlsoReportsNoIdentifier() + { + var element = await MapAsync(SheAndAllan()); + + var author = Assert.Single(element.GetProperty("authors").EnumerateArray().ToList()); + + Assert.Equal(JsonValueKind.Null, author.GetProperty("asin").ValueKind); + Assert.Equal("H. Rider Haggard", author.GetProperty("name").GetString()); + } + + [Fact] + public async Task NoSeries_ReportsAnEmptySeriesList() + { + var md = SheAndAllan(); + md.Series = null; + md.SeriesNumber = null; + + var element = await MapAsync(md); + + Assert.Empty(element.GetProperty("series").EnumerateArray().ToList()); + } + } +}