using Xunit; using Moq; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using allstarr.Services.SquidWTF; using allstarr.Services.Common; using allstarr.Models.Domain; using allstarr.Models.Settings; using System.Collections.Generic; using System.Net; using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace allstarr.Tests; public class SquidWTFMetadataServiceTests { private readonly Mock> _mockLogger; private readonly Mock _mockHttpClientFactory; private readonly IOptions _subsonicSettings; private readonly IOptions _squidwtfSettings; private readonly Mock _mockCache; private readonly List _apiUrls; public SquidWTFMetadataServiceTests() { _mockLogger = new Mock>(); _mockHttpClientFactory = new Mock(); _subsonicSettings = Options.Create(new SubsonicSettings { ExplicitFilter = ExplicitFilter.All }); _squidwtfSettings = Options.Create(new SquidWTFSettings { Quality = "FLAC" }); // Create mock Redis cache var mockRedisLogger = new Mock>(); var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); _mockCache = new Mock(mockRedisSettings, mockRedisLogger.Object); _apiUrls = new List { "https://test1.example.com", "https://test2.example.com", "https://test3.example.com" }; var httpClient = new System.Net.Http.HttpClient(); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); } [Fact] public void Constructor_InitializesWithDependencies() { // Act var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Assert Assert.NotNull(service); } [Fact] public void Constructor_AcceptsOptionalGenreEnrichment() { // Arrange - GenreEnrichmentService is optional, just pass null // Act var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls, null); // GenreEnrichmentService is optional // Assert Assert.NotNull(service); } [Fact] public void SearchSongsAsync_AcceptsQueryAndLimit() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.SearchSongsAsync("Mr. Brightside", 20); // Assert Assert.NotNull(result); } [Fact] public void SearchAlbumsAsync_AcceptsQueryAndLimit() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.SearchAlbumsAsync("Hot Fuss", 20); // Assert Assert.NotNull(result); } [Fact] public void SearchArtistsAsync_AcceptsQueryAndLimit() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.SearchArtistsAsync("The Killers", 20); // Assert Assert.NotNull(result); } [Fact] public void SearchPlaylistsAsync_AcceptsQueryAndLimit() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.SearchPlaylistsAsync("Rock Classics", 20); // Assert Assert.NotNull(result); } [Fact] public void GetSongAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetSongAsync("squidwtf", "123456"); // Assert Assert.NotNull(result); } [Fact] public void GetAlbumAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetAlbumAsync("squidwtf", "789012"); // Assert Assert.NotNull(result); } [Fact] public void GetArtistAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetArtistAsync("squidwtf", "345678"); // Assert Assert.NotNull(result); } [Fact] public void GetArtistAlbumsAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetArtistAlbumsAsync("squidwtf", "345678"); // Assert Assert.NotNull(result); } [Fact] public void GetPlaylistAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetPlaylistAsync("squidwtf", "playlist123"); // Assert Assert.NotNull(result); } [Fact] public void GetPlaylistTracksAsync_RequiresProviderAndId() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123"); // Assert Assert.NotNull(result); } [Fact] public void SearchAllAsync_CombinesAllSearchTypes() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Act var result = service.SearchAllAsync("The Killers", 20, 20, 20); // Assert Assert.NotNull(result); } [Fact] public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets() { var requestKinds = new List(); var handler = new StubHttpMessageHandler(request => { var trackQuery = GetQueryParameter(request.RequestUri!, "s"); var albumQuery = GetQueryParameter(request.RequestUri!, "al"); var artistQuery = GetQueryParameter(request.RequestUri!, "a"); if (!string.IsNullOrWhiteSpace(trackQuery)) { requestKinds.Add("song"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678"))) }; } if (!string.IsNullOrWhiteSpace(albumQuery)) { requestKinds.Add("album"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateAlbumSearchResponse()) }; } if (!string.IsNullOrWhiteSpace(artistQuery)) { requestKinds.Add("artist"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateArtistSearchResponse()) }; } throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}"); }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "https://test1.example.com" }); var result = await service.SearchAllAsync("OK Computer", 0, 5, 0); Assert.Empty(result.Songs); Assert.Single(result.Albums); Assert.Empty(result.Artists); Assert.Equal(new[] { "album" }, requestKinds); } [Fact] public void ExplicitFilter_RespectsSettings() { // Arrange - Test with CleanOnly filter var cleanOnlySettings = Options.Create(new SubsonicSettings { ExplicitFilter = ExplicitFilter.CleanOnly }); // Act var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, cleanOnlySettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); // Assert Assert.NotNull(service); } [Fact] public void MultipleApiUrls_EnablesRoundRobinFallback() { // Arrange var multipleUrls = new List { "https://test-primary.example.com", "https://test-backup1.example.com", "https://test-backup2.example.com", "https://test-backup3.example.com" }; // Act var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, multipleUrls); // Assert Assert.NotNull(service); } [Fact] public async Task GetTrackRecommendationsAsync_FallsBackWhenFirstEndpointReturnsEmpty() { var handler = new StubHttpMessageHandler(request => { var port = request.RequestUri?.Port; if (port == 5011) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "version": "2.4", "data": { "limit": 20, "offset": 0, "totalNumberOfItems": 0, "items": [] } } """) }; } if (port == 5012) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "version": "2.4", "data": { "limit": 20, "offset": 0, "totalNumberOfItems": 1, "items": [ { "track": { "id": 371921532, "title": "Take It Slow", "duration": 139, "trackNumber": 1, "volumeNumber": 1, "explicit": false, "artist": { "id": 10330497, "name": "Isaac Dunbar" }, "artists": [ { "id": 10330497, "name": "Isaac Dunbar" } ], "album": { "id": 371921525, "title": "Take It Slow", "cover": "aeb70f15-78ef-4230-929d-2d62c70ac00c" } } } ] } } """) }; } throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}"); }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5011", "http://127.0.0.1:5012" }); var result = await service.GetTrackRecommendationsAsync("227242909", 20); Assert.Single(result); Assert.Equal("371921532", result[0].ExternalId); Assert.Equal("Take It Slow", result[0].Title); } [Fact] public async Task GetSongAsync_FallsBackWhenFirstEndpointReturnsErrorPayload() { var handler = new StubHttpMessageHandler(request => { var port = request.RequestUri?.Port; if (port == 5021) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "detail": "Upstream API error" } """) }; } if (port == 5022) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "version": "2.4", "data": { "id": 227242909, "title": "Monica Lewinsky", "duration": 132, "trackNumber": 1, "volumeNumber": 1, "explicit": true, "artist": { "id": 8420542, "name": "UPSAHL" }, "artists": [ { "id": 8420542, "name": "UPSAHL" } ], "album": { "id": 227242908, "title": "Monica Lewinsky", "cover": "32522342-3903-42ab-aaea-a6f4f46ca0cc" } } } """) }; } throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}"); }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5021", "http://127.0.0.1:5022" }); var song = await service.GetSongAsync("squidwtf", "227242909"); Assert.NotNull(song); Assert.Equal("227242909", song!.ExternalId); Assert.Equal("Monica Lewinsky", song.Title); Assert.Equal(1, song.ExplicitContentLyrics); } [Fact] public async Task FindSongByIsrcAsync_UsesExactIsrcEndpoint() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload( 144371283, "Don't Look Back In Anger", "GBBQY0002027", artistName: "Oasis", artistId: 109, albumTitle: "Familiar To Millions (Live)", albumId: 144371273))) }; }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5031" }); var song = await service.FindSongByIsrcAsync("GBBQY0002027"); Assert.NotNull(song); Assert.Equal("GBBQY0002027", song!.Isrc); Assert.Equal("144371283", song.ExternalId); Assert.Contains("/search/?i=GBBQY0002027&limit=1&offset=0", requests); } [Fact] public async Task FindSongByIsrcAsync_FallsBackToTextSearchWhenExactEndpointPayloadIsUnexpected() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); if (!string.IsNullOrWhiteSpace(GetQueryParameter(request.RequestUri, "i"))) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{ "version": "2.6", "unexpected": {} }""") }; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload( 427520487, "Azizam", "GBAHS2500081", artistName: "Ed Sheeran", artistId: 3995478, albumTitle: "Azizam", albumId: 427520486))) }; }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5032" }); var song = await service.FindSongByIsrcAsync("GBAHS2500081"); Assert.NotNull(song); Assert.Equal("GBAHS2500081", song!.Isrc); Assert.Contains("/search/?i=GBAHS2500081&limit=1&offset=0", requests); Assert.Contains("/search/?s=isrc%3AGBAHS2500081&limit=1&offset=0", requests); } [Fact] public async Task SearchEndpoints_IncludeRequestedRemoteLimitAndOffset() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); var trackQuery = GetQueryParameter(request.RequestUri, "s"); var albumQuery = GetQueryParameter(request.RequestUri, "al"); var artistQuery = GetQueryParameter(request.RequestUri, "a"); var playlistQuery = GetQueryParameter(request.RequestUri, "p"); if (!string.IsNullOrWhiteSpace(trackQuery)) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678"))) }; } if (!string.IsNullOrWhiteSpace(albumQuery)) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateAlbumSearchResponse()) }; } if (!string.IsNullOrWhiteSpace(artistQuery)) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateArtistSearchResponse()) }; } if (!string.IsNullOrWhiteSpace(playlistQuery)) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreatePlaylistSearchResponse()) }; } throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}"); }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5033" }); await service.SearchSongsAsync("Take Five", 7); await service.SearchAlbumsAsync("Time Out", 8); await service.SearchArtistsAsync("Dave Brubeck", 9); await service.SearchPlaylistsAsync("Jazz Essentials", 10); Assert.Contains("/search/?s=Take%20Five&limit=7&offset=0", requests); Assert.Contains("/search/?al=Time%20Out&limit=8&offset=0", requests); Assert.Contains("/search/?a=Dave%20Brubeck&limit=9&offset=0", requests); Assert.Contains("/search/?p=Jazz%20Essentials&limit=10&offset=0", requests); } [Fact] public async Task GetArtistAsync_UsesLightweightArtistEndpointAndCoverFallback() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "version": "2.6", "artist": { "id": 25022, "name": "Kanye West", "picture": null }, "cover": { "750": "https://example.com/kanye-750.jpg" } } """) }; }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5034" }); var artist = await service.GetArtistAsync("squidwtf", "25022"); Assert.Contains("/artist/?id=25022", requests); Assert.NotNull(artist); Assert.Equal("Kanye West", artist!.Name); Assert.Equal("https://example.com/kanye-750.jpg", artist.ImageUrl); Assert.Null(artist.AlbumCount); } [Fact] public async Task GetAlbumAsync_PaginatesBeyondFirstPage() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); var offset = int.Parse(GetQueryParameter(request.RequestUri, "offset") ?? "0"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreateAlbumPageResponse(offset, offset == 0 ? 500 : 1, totalTracks: 501)) }; }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5035" }); var album = await service.GetAlbumAsync("squidwtf", "58990510"); Assert.Contains("/album/?id=58990510&limit=500&offset=0", requests); Assert.Contains("/album/?id=58990510&limit=500&offset=500", requests); Assert.NotNull(album); Assert.Equal(501, album!.Songs.Count); } [Fact] public async Task GetPlaylistTracksAsync_PaginatesBeyondFirstPage() { var requests = new List(); var handler = new StubHttpMessageHandler(request => { requests.Add(request.RequestUri!.PathAndQuery); var offset = int.Parse(GetQueryParameter(request.RequestUri, "offset") ?? "0"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(CreatePlaylistPageResponse(offset, offset == 0 ? 500 : 1, totalTracks: 501)) }; }); var httpClient = new HttpClient(handler); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, new List { "http://127.0.0.1:5036" }); var songs = await service.GetPlaylistTracksAsync("squidwtf", "playlist123"); Assert.Equal(501, songs.Count); Assert.Equal("Big Playlist", songs[0].Album); Assert.Equal("Big Playlist", songs[^1].Album); Assert.Contains("/playlist/?id=playlist123&limit=500&offset=0", requests); Assert.Contains("/playlist/?id=playlist123&limit=500&offset=500", requests); } [Fact] public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant() { var variants = InvokePrivateStaticMethod>( typeof(SquidWTFMetadataService), "BuildSearchQueryVariants", "love & hyperbole"); Assert.Equal(2, variants.Count); Assert.Contains("love & hyperbole", variants); Assert.Contains("love and hyperbole", variants); } [Fact] public void BuildSearchQueryVariants_WithoutAmpersand_KeepsOriginalOnly() { var variants = InvokePrivateStaticMethod>( typeof(SquidWTFMetadataService), "BuildSearchQueryVariants", "love and hyperbole"); Assert.Single(variants); Assert.Equal("love and hyperbole", variants[0]); } [Fact] public void ParseTidalTrack_MapsFieldsUsedByJellyfinAndTagWriter() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); using var doc = JsonDocument.Parse(""" { "id": 452455962, "title": "Stuck Up", "version": "Live", "duration": 151, "trackNumber": 1, "volumeNumber": 1, "explicit": true, "bpm": 130, "isrc": "USUG12504959", "streamStartDate": "2025-08-08T00:00:00.000+0000", "copyright": "℗ 2025 Golden Angel LLC, under exclusive license to Interscope Records.", "artists": [ { "id": 9321197, "name": "Amaarae" }, { "id": 30396, "name": "Black Star" } ], "album": { "id": 452455961, "title": "BLACK STAR", "cover": "87f0be2b-dd7e-42d4-b438-f8f161d29674", "numberOfTracks": 13, "releaseDate": "2025-08-08", "artist": { "id": 9321197, "name": "Amaarae" } } } """); // Act var song = InvokePrivateMethod(service, "ParseTidalTrack", doc.RootElement, null); // Assert Assert.Equal("ext-squidwtf-song-452455962", song.Id); Assert.Equal("Stuck Up (Live)", song.Title); Assert.Equal("Amaarae", song.Artist); Assert.Equal("Amaarae", song.AlbumArtist); Assert.Equal("USUG12504959", song.Isrc); Assert.Equal(130, song.Bpm); Assert.Equal("2025-08-08", song.ReleaseDate); Assert.Equal(2025, song.Year); Assert.Equal(13, song.TotalTracks); Assert.Equal("℗ 2025 Golden Angel LLC, under exclusive license to Interscope Records.", song.Copyright); Assert.Equal("Black Star", Assert.Single(song.Contributors)); Assert.Contains("/87f0be2b/dd7e/42d4/b438/f8f161d29674/320x320.jpg", song.CoverArtUrl); Assert.Contains("/87f0be2b/dd7e/42d4/b438/f8f161d29674/1280x1280.jpg", song.CoverArtUrlLarge); } [Fact] public void ParseTidalTrackFull_MapsCopyrightToCopyrightField() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); using var doc = JsonDocument.Parse(""" { "id": 987654, "title": "Night Walk", "duration": 200, "trackNumber": 7, "volumeNumber": 1, "streamStartDate": "2024-02-01T00:00:00.000+0000", "copyright": "℗ 2024 Example Label", "artist": { "id": 111, "name": "Main Artist" }, "album": { "id": 222, "title": "Moonlight", "cover": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" } } """); // Act var song = InvokePrivateMethod(service, "ParseTidalTrackFull", doc.RootElement); // Assert Assert.Equal("℗ 2024 Example Label", song.Copyright); Assert.Null(song.Label); Assert.Equal(2024, song.Year); } [Fact] public void ParseTidalPlaylist_UsesPromotedArtistsAndFallbackMetadata() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); using var doc = JsonDocument.Parse(""" { "uuid": "b55ffed4-ab60-4da5-9faf-e54a45de4f9c", "title": "Guest Verses: BIG30", "description": "Remixes and guest verses", "creator": { "id": 0 }, "promotedArtists": [ { "id": 19872911, "name": "BigWalkDog" } ], "lastUpdated": "2022-09-23T17:52:48.974+0000", "image": "75ed74c0-58d8-4af7-a4c0-cbae0315dc34", "numberOfTracks": 32, "duration": 5466 } """); // Act var playlist = InvokePrivateMethod(service, "ParseTidalPlaylist", doc.RootElement); // Assert Assert.Equal("BigWalkDog", playlist.CuratorName); Assert.Equal(32, playlist.TrackCount); Assert.Equal(5466, playlist.Duration); Assert.True(playlist.CreatedDate.HasValue); Assert.Equal(2022, playlist.CreatedDate!.Value.Year); Assert.Contains("/75ed74c0/58d8/4af7/a4c0/cbae0315dc34/1080x1080.jpg", playlist.CoverUrl); } [Fact] public void ParseTidalAlbum_AppendsVersionAndParsesYearFallback() { // Arrange var service = new SquidWTFMetadataService( _mockHttpClientFactory.Object, _subsonicSettings, _squidwtfSettings, _mockLogger.Object, _mockCache.Object, _apiUrls); using var doc = JsonDocument.Parse(""" { "id": 579814, "title": "Black Star", "version": "Remastered", "streamStartDate": "2002-06-04T00:00:00.000+0000", "numberOfTracks": 13, "cover": "49fcdc8b-2f43-43a9-b156-f2f83908f95f", "artists": [ { "id": 30396, "name": "Black Star" } ] } """); // Act var album = InvokePrivateMethod(service, "ParseTidalAlbum", doc.RootElement); // Assert Assert.Equal("Black Star (Remastered)", album.Title); Assert.Equal(2002, album.Year); Assert.Equal(13, album.SongCount); Assert.Contains("/49fcdc8b/2f43/43a9/b156/f2f83908f95f/320x320.jpg", album.CoverArtUrl); } private static T InvokePrivateMethod(object target, string methodName, params object?[] parameters) { var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(method); var result = method!.Invoke(target, parameters); Assert.NotNull(result); return (T)result!; } private static T InvokePrivateStaticMethod(Type targetType, string methodName, params object?[] parameters) { var method = targetType.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(method); var result = method!.Invoke(null, parameters); Assert.NotNull(result); return (T)result!; } private static string CreateTrackSearchResponse(object trackPayload) { return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["data"] = new Dictionary { ["limit"] = 25, ["offset"] = 0, ["totalNumberOfItems"] = 1, ["items"] = new[] { trackPayload } } }); } private static string CreateAlbumSearchResponse() { return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["data"] = new Dictionary { ["albums"] = new Dictionary { ["limit"] = 25, ["offset"] = 0, ["totalNumberOfItems"] = 1, ["items"] = new[] { new Dictionary { ["id"] = 58990510, ["title"] = "OK Computer", ["numberOfTracks"] = 12, ["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065", ["artist"] = new Dictionary { ["id"] = 64518, ["name"] = "Radiohead" } } } } } }); } private static string CreateArtistSearchResponse() { return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["data"] = new Dictionary { ["artists"] = new Dictionary { ["limit"] = 25, ["offset"] = 0, ["totalNumberOfItems"] = 1, ["items"] = new[] { new Dictionary { ["id"] = 8812, ["name"] = "Coldplay", ["picture"] = "b4579672-5b91-4679-a27a-288f097a4da5" } } } } }); } private static string CreatePlaylistSearchResponse() { return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["data"] = new Dictionary { ["playlists"] = new Dictionary { ["limit"] = 25, ["offset"] = 0, ["totalNumberOfItems"] = 1, ["items"] = new[] { new Dictionary { ["uuid"] = "playlist123", ["title"] = "Jazz Essentials", ["creator"] = new Dictionary { ["id"] = 0 }, ["numberOfTracks"] = 1, ["duration"] = 180, ["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669" } } } } }); } private static string CreateAlbumPageResponse(int offset, int count, int totalTracks) { var items = Enumerable.Range(offset + 1, count) .Select(index => (object)new Dictionary { ["item"] = CreateTrackPayload( index, $"Album Track {index}", $"USRC{index:00000000}", albumTitle: "Paginated Album", albumId: 58990510) }) .ToArray(); return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["data"] = new Dictionary { ["id"] = 58990510, ["title"] = "Paginated Album", ["numberOfTracks"] = totalTracks, ["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065", ["artist"] = new Dictionary { ["id"] = 64518, ["name"] = "Radiohead" }, ["items"] = items } }); } private static string CreatePlaylistPageResponse(int offset, int count, int totalTracks) { var items = Enumerable.Range(offset + 1, count) .Select(index => (object)new Dictionary { ["item"] = CreateTrackPayload( index, $"Playlist Track {index}", $"GBARL{index:0000000}", artistName: "Mark Ronson", artistId: 8722, albumTitle: "Uptown Special", albumId: 39249709) }) .ToArray(); return JsonSerializer.Serialize(new Dictionary { ["version"] = "2.6", ["playlist"] = new Dictionary { ["uuid"] = "playlist123", ["title"] = "Big Playlist", ["creator"] = new Dictionary { ["id"] = 0 }, ["numberOfTracks"] = totalTracks, ["duration"] = totalTracks * 180, ["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669" }, ["items"] = items }); } private static Dictionary CreateTrackPayload( int id, string title, string isrc, string artistName = "Artist", int artistId = 1, string albumTitle = "Album", int albumId = 10) { return new Dictionary { ["id"] = id, ["title"] = title, ["duration"] = 180, ["trackNumber"] = (id % 12) + 1, ["volumeNumber"] = 1, ["explicit"] = false, ["isrc"] = isrc, ["artist"] = new Dictionary { ["id"] = artistId, ["name"] = artistName }, ["artists"] = new object[] { new Dictionary { ["id"] = artistId, ["name"] = artistName } }, ["album"] = new Dictionary { ["id"] = albumId, ["title"] = albumTitle, ["cover"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" } }; } private static string? GetQueryParameter(Uri uri, string name) { var query = uri.Query.TrimStart('?'); if (string.IsNullOrWhiteSpace(query)) { return null; } foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var parts = pair.Split('=', 2); var key = Uri.UnescapeDataString(parts[0]); if (!key.Equals(name, StringComparison.Ordinal)) { continue; } return parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; } return null; } private sealed class StubHttpMessageHandler : HttpMessageHandler { private readonly Func _handler; public StubHttpMessageHandler(Func handler) { _handler = handler; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(_handler(request)); } } }