feat: Fork octo-fiestarr as allstarr with Jellyfin proxy improvements

Major changes:
- Rename project from octo-fiesta to allstarr
- Add Jellyfin proxy support alongside Subsonic/Navidrome
- Implement fuzzy search with relevance scoring and Levenshtein distance
- Add POST body logging for debugging playback progress issues
- Separate local and external artists in search results
- Add +5 score boost for external results to prioritize larger catalog(probably gonna reverse it)
- Create FuzzyMatcher utility for intelligent search result scoring
- Add ConvertPlaylistToJellyfinItem method for playlist support
- Rename keys folder to apis and update gitignore
- Filter search results by relevance score (>= 40)
- Add Redis caching support with configurable settings
- Update environment configuration with backend selection
- Improve external provider integration (SquidWTF, Deezer, Qobuz)
- Add tests for all services
This commit is contained in:
2026-01-29 17:36:53 -05:00
parent ed9cec1cde
commit e18840cddf
87 changed files with 166973 additions and 607 deletions

View File

@@ -0,0 +1,476 @@
using allstarr.Services;
using allstarr.Services.Deezer;
using allstarr.Services.Local;
using allstarr.Services.Common;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using System.Net;
using System.Text.Json;
namespace allstarr.Tests;
public class DeezerDownloadServiceTests : IDisposable
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock;
private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<DeezerDownloadService>> _loggerMock;
private readonly IConfiguration _configuration;
private readonly string _testDownloadPath;
public DeezerDownloadServiceTests()
{
_testDownloadPath = Path.Combine(Path.GetTempPath(), "allstarr-download-tests-" + Guid.NewGuid());
Directory.CreateDirectory(_testDownloadPath);
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
_localLibraryServiceMock = new Mock<ILocalLibraryService>();
_metadataServiceMock = new Mock<IMusicMetadataService>();
_loggerMock = new Mock<ILogger<DeezerDownloadService>>();
_configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath,
["Deezer:Arl"] = null,
["Deezer:ArlFallback"] = null
})
.Build();
}
public void Dispose()
{
if (Directory.Exists(_testDownloadPath))
{
Directory.Delete(_testDownloadPath, true);
}
}
private DeezerDownloadService CreateService(string? arl = null, DownloadMode downloadMode = DownloadMode.Track)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath,
["Deezer:Arl"] = arl,
["Deezer:ArlFallback"] = null
})
.Build();
var subsonicSettings = Options.Create(new SubsonicSettings
{
DownloadMode = downloadMode
});
var deezerSettings = Options.Create(new DeezerSettings
{
Arl = arl,
ArlFallback = null,
Quality = null
});
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock.Setup(sp => sp.GetService(typeof(allstarr.Services.Subsonic.PlaylistSyncService)))
.Returns(null);
return new DeezerDownloadService(
_httpClientFactoryMock.Object,
config,
_localLibraryServiceMock.Object,
_metadataServiceMock.Object,
subsonicSettings,
deezerSettings,
serviceProviderMock.Object,
_loggerMock.Object);
}
[Fact]
public async Task IsAvailableAsync_WithoutArl_ReturnsFalse()
{
// Arrange
var service = CreateService(arl: null);
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_WithEmptyArl_ReturnsFalse()
{
// Arrange
var service = CreateService(arl: "");
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task DownloadSongAsync_WithUnsupportedProvider_ThrowsNotSupportedException()
{
// Arrange
var service = CreateService(arl: "test-arl");
// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(() =>
service.DownloadSongAsync("spotify", "123456"));
}
[Fact]
public async Task DownloadSongAsync_WhenAlreadyDownloaded_ReturnsExistingPath()
{
// Arrange
var existingPath = Path.Combine(_testDownloadPath, "existing-song.mp3");
await File.WriteAllTextAsync(existingPath, "fake audio content");
_localLibraryServiceMock
.Setup(s => s.GetLocalPathForExternalSongAsync("deezer", "123456"))
.ReturnsAsync(existingPath);
var service = CreateService(arl: "test-arl");
// Act
var result = await service.DownloadSongAsync("deezer", "123456");
// Assert
Assert.Equal(existingPath, result);
}
[Fact]
public void GetDownloadStatus_WithUnknownSongId_ReturnsNull()
{
// Arrange
var service = CreateService(arl: "test-arl");
// Act
var result = service.GetDownloadStatus("unknown-id");
// Assert
Assert.Null(result);
}
[Fact]
public async Task DownloadSongAsync_WhenSongNotFound_ThrowsException()
{
// Arrange
_localLibraryServiceMock
.Setup(s => s.GetLocalPathForExternalSongAsync("deezer", "999999"))
.ReturnsAsync((string?)null);
_metadataServiceMock
.Setup(s => s.GetSongAsync("deezer", "999999"))
.ReturnsAsync((Song?)null);
var service = CreateService(arl: "test-arl");
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(() =>
service.DownloadSongAsync("deezer", "999999"));
Assert.Equal("Song not found", exception.Message);
}
[Fact]
public void DownloadRemainingAlbumTracksInBackground_WithUnsupportedProvider_DoesNotThrow()
{
// Arrange
var service = CreateService(arl: "test-arl", downloadMode: DownloadMode.Album);
// Act & Assert - Should not throw, just log warning
service.DownloadRemainingAlbumTracksInBackground("spotify", "123456", "789");
}
[Fact]
public void DownloadRemainingAlbumTracksInBackground_WithDeezerProvider_StartsBackgroundTask()
{
// Arrange
_metadataServiceMock
.Setup(s => s.GetAlbumAsync("deezer", "123456"))
.ReturnsAsync(new Album
{
Id = "ext-deezer-album-123456",
Title = "Test Album",
Songs = new List<Song>
{
new Song { ExternalId = "111", Title = "Track 1" },
new Song { ExternalId = "222", Title = "Track 2" }
}
});
var service = CreateService(arl: "test-arl", downloadMode: DownloadMode.Album);
// Act - Should not throw (fire-and-forget)
service.DownloadRemainingAlbumTracksInBackground("deezer", "123456", "111");
// Assert - Just verify it doesn't throw, actual download is async
Assert.True(true);
}
}
/// <summary>
/// Unit tests for the PathHelper class that handles file organization logic.
/// </summary>
public class PathHelperTests : IDisposable
{
private readonly string _testPath;
public PathHelperTests()
{
_testPath = Path.Combine(Path.GetTempPath(), "allstarr-pathhelper-tests-" + Guid.NewGuid());
Directory.CreateDirectory(_testPath);
}
public void Dispose()
{
if (Directory.Exists(_testPath))
{
Directory.Delete(_testPath, true);
}
}
#region SanitizeFileName Tests
[Fact]
public void SanitizeFileName_WithValidName_ReturnsUnchanged()
{
// Arrange & Act
var result = PathHelper.SanitizeFileName("My Song Title");
// Assert
Assert.Equal("My Song Title", result);
}
[Fact]
public void SanitizeFileName_WithInvalidChars_ReplacesWithUnderscore()
{
// Arrange - Use forward slash which is invalid on all platforms
var result = PathHelper.SanitizeFileName("Song/With/Invalid");
// Assert - Check that forward slashes were replaced with underscores
Assert.Equal("Song_With_Invalid", result);
}
[Fact]
public void SanitizeFileName_WithNullOrEmpty_ReturnsUnknown()
{
// Arrange & Act
var resultNull = PathHelper.SanitizeFileName(null!);
var resultEmpty = PathHelper.SanitizeFileName("");
var resultWhitespace = PathHelper.SanitizeFileName(" ");
// Assert
Assert.Equal("Unknown", resultNull);
Assert.Equal("Unknown", resultEmpty);
Assert.Equal("Unknown", resultWhitespace);
}
[Fact]
public void SanitizeFileName_WithLongName_TruncatesTo100Chars()
{
// Arrange
var longName = new string('A', 150);
// Act
var result = PathHelper.SanitizeFileName(longName);
// Assert
Assert.Equal(100, result.Length);
}
#endregion
#region SanitizeFolderName Tests
[Fact]
public void SanitizeFolderName_WithValidName_ReturnsUnchanged()
{
// Arrange & Act
var result = PathHelper.SanitizeFolderName("Artist Name");
// Assert
Assert.Equal("Artist Name", result);
}
[Fact]
public void SanitizeFolderName_WithNullOrEmpty_ReturnsUnknown()
{
// Arrange & Act
var resultNull = PathHelper.SanitizeFolderName(null!);
var resultEmpty = PathHelper.SanitizeFolderName("");
var resultWhitespace = PathHelper.SanitizeFolderName(" ");
// Assert
Assert.Equal("Unknown", resultNull);
Assert.Equal("Unknown", resultEmpty);
Assert.Equal("Unknown", resultWhitespace);
}
[Fact]
public void SanitizeFolderName_WithTrailingDots_RemovesDots()
{
// Arrange & Act
var result = PathHelper.SanitizeFolderName("Artist Name...");
// Assert
Assert.Equal("Artist Name", result);
}
[Fact]
public void SanitizeFolderName_WithInvalidChars_ReplacesWithUnderscore()
{
// Arrange - Use forward slash which is invalid on all platforms
var result = PathHelper.SanitizeFolderName("Artist/With/Invalid");
// Assert - Check that forward slashes were replaced with underscores
Assert.Equal("Artist_With_Invalid", result);
}
#endregion
#region BuildTrackPath Tests
[Fact]
public void BuildTrackPath_WithAllParameters_CreatesCorrectStructure()
{
// Arrange
var downloadPath = "/downloads";
var artist = "Test Artist";
var album = "Test Album";
var title = "Test Song";
var trackNumber = 5;
var extension = ".mp3";
// Act
var result = PathHelper.BuildTrackPath(downloadPath, artist, album, title, trackNumber, extension);
// Assert
Assert.Contains("Test Artist", result);
Assert.Contains("Test Album", result);
Assert.Contains("05 - Test Song.mp3", result);
}
[Fact]
public void BuildTrackPath_WithoutTrackNumber_OmitsTrackPrefix()
{
// Arrange
var downloadPath = "/downloads";
var artist = "Test Artist";
var album = "Test Album";
var title = "Test Song";
var extension = ".mp3";
// Act
var result = PathHelper.BuildTrackPath(downloadPath, artist, album, title, null, extension);
// Assert
Assert.Contains("Test Song.mp3", result);
Assert.DoesNotContain(" - Test Song", result.Split(Path.DirectorySeparatorChar).Last());
}
[Fact]
public void BuildTrackPath_WithSingleDigitTrack_PadsWithZero()
{
// Arrange & Act
var result = PathHelper.BuildTrackPath("/downloads", "Artist", "Album", "Song", 3, ".mp3");
// Assert
Assert.Contains("03 - Song.mp3", result);
}
[Fact]
public void BuildTrackPath_WithFlacExtension_UsesFlacExtension()
{
// Arrange & Act
var result = PathHelper.BuildTrackPath("/downloads", "Artist", "Album", "Song", 1, ".flac");
// Assert
Assert.EndsWith(".flac", result);
}
[Fact]
public void BuildTrackPath_CreatesArtistAlbumHierarchy()
{
// Arrange & Act
var result = PathHelper.BuildTrackPath("/downloads", "My Artist", "My Album", "My Song", 1, ".mp3");
// Assert
// Verify the structure is: downloadPath/Artist/Album/track.mp3
var parts = result.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
Assert.Contains("My Artist", parts);
Assert.Contains("My Album", parts);
// Artist should come before Album in the path
var artistIndex = Array.IndexOf(parts, "My Artist");
var albumIndex = Array.IndexOf(parts, "My Album");
Assert.True(artistIndex < albumIndex, "Artist folder should be parent of Album folder");
}
#endregion
#region ResolveUniquePath Tests
[Fact]
public void ResolveUniquePath_WhenFileDoesNotExist_ReturnsSamePath()
{
// Arrange
var path = Path.Combine(_testPath, "nonexistent.mp3");
// Act
var result = PathHelper.ResolveUniquePath(path);
// Assert
Assert.Equal(path, result);
}
[Fact]
public void ResolveUniquePath_WhenFileExists_ReturnsPathWithCounter()
{
// Arrange
var basePath = Path.Combine(_testPath, "existing.mp3");
File.WriteAllText(basePath, "content");
// Act
var result = PathHelper.ResolveUniquePath(basePath);
// Assert
Assert.NotEqual(basePath, result);
Assert.Contains("existing (1).mp3", result);
}
[Fact]
public void ResolveUniquePath_WhenMultipleFilesExist_IncrementsCounter()
{
// Arrange
var basePath = Path.Combine(_testPath, "song.mp3");
var path1 = Path.Combine(_testPath, "song (1).mp3");
File.WriteAllText(basePath, "content");
File.WriteAllText(path1, "content");
// Act
var result = PathHelper.ResolveUniquePath(basePath);
// Assert
Assert.Contains("song (2).mp3", result);
}
#endregion
}

View File

@@ -0,0 +1,805 @@
using allstarr.Services.Deezer;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using Moq;
using Moq.Protected;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text.Json;
namespace allstarr.Tests;
public class DeezerMetadataServiceTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly SubsonicSettings _settings;
private DeezerMetadataService _service;
public DeezerMetadataServiceTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
_settings = new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly };
_service = CreateService(_settings);
}
private DeezerMetadataService CreateService(SubsonicSettings settings)
{
var options = Options.Create(settings);
return new DeezerMetadataService(_httpClientFactoryMock.Object, options);
}
[Fact]
public async Task SearchSongsAsync_ReturnsListOfSongs()
{
// Arrange
var deezerResponse = new
{
data = new[]
{
new
{
id = 123456,
title = "Test Song",
duration = 180,
track_position = 1,
artist = new { id = 789, name = "Test Artist" },
album = new { id = 456, title = "Test Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test query", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("ext-deezer-song-123456", result[0].Id);
Assert.Equal("Test Song", result[0].Title);
Assert.Equal("Test Artist", result[0].Artist);
Assert.Equal("Test Album", result[0].Album);
Assert.Equal(180, result[0].Duration);
Assert.False(result[0].IsLocal);
Assert.Equal("deezer", result[0].ExternalProvider);
}
[Fact]
public async Task SearchAlbumsAsync_ReturnsListOfAlbums()
{
// Arrange
var deezerResponse = new
{
data = new[]
{
new
{
id = 456789,
title = "Test Album",
nb_tracks = 12,
release_date = "2023-01-15",
cover_medium = "https://example.com/album.jpg",
artist = new { id = 123, name = "Test Artist" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchAlbumsAsync("test album", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("ext-deezer-album-456789", result[0].Id);
Assert.Equal("Test Album", result[0].Title);
Assert.Equal("Test Artist", result[0].Artist);
Assert.Equal(12, result[0].SongCount);
Assert.Equal(2023, result[0].Year);
Assert.False(result[0].IsLocal);
}
[Fact]
public async Task SearchArtistsAsync_ReturnsListOfArtists()
{
// Arrange
var deezerResponse = new
{
data = new[]
{
new
{
id = 789012,
name = "Test Artist",
nb_album = 5,
picture_medium = "https://example.com/artist.jpg"
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchArtistsAsync("test artist", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("ext-deezer-artist-789012", result[0].Id);
Assert.Equal("Test Artist", result[0].Name);
Assert.Equal(5, result[0].AlbumCount);
Assert.False(result[0].IsLocal);
}
[Fact]
public async Task SearchAllAsync_ReturnsAllTypes()
{
// This test would need multiple HTTP calls mocked, simplified for now
var emptyResponse = JsonSerializer.Serialize(new { data = Array.Empty<object>() });
SetupHttpResponse(emptyResponse);
// Act
var result = await _service.SearchAllAsync("test");
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Songs);
Assert.NotNull(result.Albums);
Assert.NotNull(result.Artists);
}
[Fact]
public async Task GetSongAsync_WithDeezerProvider_ReturnsSong()
{
// Arrange
var deezerResponse = new
{
id = 123456,
title = "Test Song",
duration = 200,
track_position = 3,
artist = new { id = 789, name = "Test Artist" },
album = new { id = 456, title = "Test Album", cover_medium = "https://example.com/cover.jpg" }
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetSongAsync("deezer", "123456");
// Assert
Assert.NotNull(result);
Assert.Equal("ext-deezer-song-123456", result.Id);
Assert.Equal("Test Song", result.Title);
}
[Fact]
public async Task GetSongAsync_WithNonDeezerProvider_ReturnsNull()
{
// Act
var result = await _service.GetSongAsync("spotify", "123456");
// Assert
Assert.Null(result);
}
[Fact]
public async Task SearchSongsAsync_WithEmptyResponse_ReturnsEmptyList()
{
// Arrange
SetupHttpResponse(JsonSerializer.Serialize(new { data = Array.Empty<object>() }));
// Act
var result = await _service.SearchSongsAsync("nonexistent", 20);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task SearchSongsAsync_WithHttpError_ReturnsEmptyList()
{
// Arrange
SetupHttpResponse("Error", HttpStatusCode.InternalServerError);
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetAlbumAsync_WithDeezerProvider_ReturnsAlbumWithTracks()
{
// Arrange
var deezerResponse = new
{
id = 456789,
title = "Test Album",
nb_tracks = 2,
release_date = "2023-05-20",
cover_medium = "https://example.com/album.jpg",
artist = new { id = 123, name = "Test Artist" },
tracks = new
{
data = new[]
{
new
{
id = 111,
title = "Track 1",
duration = 180,
track_position = 1,
artist = new { id = 123, name = "Test Artist" },
album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" }
},
new
{
id = 222,
title = "Track 2",
duration = 200,
track_position = 2,
artist = new { id = 123, name = "Test Artist" },
album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" }
}
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetAlbumAsync("deezer", "456789");
// Assert
Assert.NotNull(result);
Assert.Equal("ext-deezer-album-456789", result.Id);
Assert.Equal("Test Album", result.Title);
Assert.Equal("Test Artist", result.Artist);
Assert.Equal(2, result.Songs.Count);
Assert.Equal("Track 1", result.Songs[0].Title);
Assert.Equal("Track 2", result.Songs[1].Title);
}
[Fact]
public async Task GetAlbumAsync_WithNonDeezerProvider_ReturnsNull()
{
// Act
var result = await _service.GetAlbumAsync("spotify", "123456");
// Assert
Assert.Null(result);
}
private void SetupHttpResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content)
});
}
#region Explicit Filter Tests
[Fact]
public async Task SearchSongsAsync_ExplicitOnlyFilter_ExcludesCleanVersions()
{
// Arrange
_service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly });
var deezerResponse = new
{
data = new object[]
{
new
{
id = 1,
title = "Explicit Original",
duration = 180,
explicit_content_lyrics = 1, // Explicit
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 2,
title = "Clean Version",
duration = 180,
explicit_content_lyrics = 3, // Clean/edited - should be excluded
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 3,
title = "Naturally Clean",
duration = 180,
explicit_content_lyrics = 0, // Naturally clean - should be included
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(result, s => s.Title == "Explicit Original");
Assert.Contains(result, s => s.Title == "Naturally Clean");
Assert.DoesNotContain(result, s => s.Title == "Clean Version");
}
[Fact]
public async Task SearchSongsAsync_CleanOnlyFilter_ExcludesExplicitContent()
{
// Arrange
_service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.CleanOnly });
var deezerResponse = new
{
data = new object[]
{
new
{
id = 1,
title = "Explicit Original",
duration = 180,
explicit_content_lyrics = 1, // Explicit - should be excluded
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 2,
title = "Clean Version",
duration = 180,
explicit_content_lyrics = 3, // Clean/edited - should be included
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 3,
title = "Naturally Clean",
duration = 180,
explicit_content_lyrics = 0, // Naturally clean - should be included
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(result, s => s.Title == "Clean Version");
Assert.Contains(result, s => s.Title == "Naturally Clean");
Assert.DoesNotContain(result, s => s.Title == "Explicit Original");
}
[Fact]
public async Task SearchSongsAsync_AllFilter_IncludesEverything()
{
// Arrange
_service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.All });
var deezerResponse = new
{
data = new object[]
{
new
{
id = 1,
title = "Explicit Original",
duration = 180,
explicit_content_lyrics = 1,
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 2,
title = "Clean Version",
duration = 180,
explicit_content_lyrics = 3,
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
},
new
{
id = 3,
title = "Naturally Clean",
duration = 180,
explicit_content_lyrics = 0,
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.Equal(3, result.Count);
}
[Fact]
public async Task SearchSongsAsync_ExplicitOnlyFilter_IncludesTracksWithNoExplicitInfo()
{
// Arrange
_service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly });
var deezerResponse = new
{
data = new object[]
{
new
{
id = 1,
title = "No Explicit Info",
duration = 180,
// No explicit_content_lyrics field
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.Single(result);
Assert.Equal("No Explicit Info", result[0].Title);
}
[Fact]
public async Task GetAlbumAsync_ExplicitOnlyFilter_FiltersAlbumTracks()
{
// Arrange
_service = CreateService(new SubsonicSettings { ExplicitFilter = ExplicitFilter.ExplicitOnly });
var deezerResponse = new
{
id = 456789,
title = "Test Album",
nb_tracks = 3,
release_date = "2023-05-20",
cover_medium = "https://example.com/album.jpg",
artist = new { id = 123, name = "Test Artist" },
tracks = new
{
data = new object[]
{
new
{
id = 111,
title = "Explicit Track",
duration = 180,
explicit_content_lyrics = 1,
artist = new { id = 123, name = "Test Artist" },
album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" }
},
new
{
id = 222,
title = "Clean Version Track",
duration = 200,
explicit_content_lyrics = 3, // Should be excluded
artist = new { id = 123, name = "Test Artist" },
album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" }
},
new
{
id = 333,
title = "Naturally Clean Track",
duration = 220,
explicit_content_lyrics = 0,
artist = new { id = 123, name = "Test Artist" },
album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" }
}
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetAlbumAsync("deezer", "456789");
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Songs.Count);
Assert.Contains(result.Songs, s => s.Title == "Explicit Track");
Assert.Contains(result.Songs, s => s.Title == "Naturally Clean Track");
Assert.DoesNotContain(result.Songs, s => s.Title == "Clean Version Track");
}
[Fact]
public async Task SearchSongsAsync_ParsesExplicitContentLyrics()
{
// Arrange
var deezerResponse = new
{
data = new object[]
{
new
{
id = 1,
title = "Test Track",
duration = 180,
explicit_content_lyrics = 1,
artist = new { id = 100, name = "Artist" },
album = new { id = 200, title = "Album", cover_medium = "https://example.com/cover.jpg" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchSongsAsync("test", 20);
// Assert
Assert.Single(result);
Assert.Equal(1, result[0].ExplicitContentLyrics);
}
#endregion
#region Playlist Tests
[Fact]
public async Task SearchPlaylistsAsync_ReturnsListOfPlaylists()
{
// Arrange
var deezerResponse = new
{
data = new[]
{
new
{
id = 12345,
title = "Chill Vibes",
nb_tracks = 50,
picture_medium = "https://example.com/playlist1.jpg",
user = new { name = "Test User" }
},
new
{
id = 67890,
title = "Workout Mix",
nb_tracks = 30,
picture_medium = "https://example.com/playlist2.jpg",
user = new { name = "Gym Buddy" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchPlaylistsAsync("chill");
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("Chill Vibes", result[0].Name);
Assert.Equal(50, result[0].TrackCount);
Assert.Equal("pl-deezer-12345", result[0].Id);
}
[Fact]
public async Task SearchPlaylistsAsync_WithLimit_RespectsLimit()
{
// Arrange
var deezerResponse = new
{
data = new[]
{
new
{
id = 12345,
title = "Playlist 1",
nb_tracks = 10,
picture_medium = "https://example.com/p1.jpg",
user = new { name = "User 1" }
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchPlaylistsAsync("test", 1);
// Assert
Assert.Single(result);
}
[Fact]
public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList()
{
// Arrange
var deezerResponse = new
{
data = new object[] { }
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.SearchPlaylistsAsync("nonexistent");
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist()
{
// Arrange
var deezerResponse = new
{
id = 12345,
title = "Best Of Jazz",
description = "The best jazz tracks",
nb_tracks = 100,
picture_medium = "https://example.com/jazz.jpg",
user = new { name = "Jazz Lover" }
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetPlaylistAsync("deezer", "12345");
// Assert
Assert.NotNull(result);
Assert.Equal("Best Of Jazz", result.Name);
Assert.Equal(100, result.TrackCount);
Assert.Equal("pl-deezer-12345", result.Id);
}
[Fact]
public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull()
{
// Act
var result = await _service.GetPlaylistAsync("qobuz", "12345");
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetPlaylistTracksAsync_ReturnsListOfSongs()
{
// Arrange
var deezerResponse = new
{
tracks = new
{
data = new[]
{
new
{
id = 111,
title = "Track 1",
duration = 200,
track_position = 1,
disk_number = 1,
artist = new
{
id = 999,
name = "Artist A"
},
album = new
{
id = 888,
title = "Album X",
release_date = "2020-01-15",
cover_medium = "https://example.com/cover.jpg"
}
},
new
{
id = 222,
title = "Track 2",
duration = 180,
track_position = 2,
disk_number = 1,
artist = new
{
id = 777,
name = "Artist B"
},
album = new
{
id = 666,
title = "Album Y",
release_date = "2021-05-20",
cover_medium = "https://example.com/cover2.jpg"
}
}
}
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("Track 1", result[0].Title);
Assert.Equal("Artist A", result[0].Artist);
Assert.Equal("ext-deezer-song-111", result[0].Id);
}
[Fact]
public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList()
{
// Act
var result = await _service.GetPlaylistTracksAsync("qobuz", "12345");
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetPlaylistTracksAsync_WithEmptyPlaylist_ReturnsEmptyList()
{
// Arrange
var deezerResponse = new
{
tracks = new
{
data = new object[] { }
}
};
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
// Act
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
// Assert
Assert.Empty(result);
}
#endregion
}

View File

@@ -0,0 +1,401 @@
using Microsoft.Extensions.Logging;
using Moq;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Jellyfin;
using System.Text.Json;
namespace allstarr.Tests;
public class JellyfinModelMapperTests
{
private readonly JellyfinModelMapper _mapper;
private readonly JellyfinResponseBuilder _responseBuilder;
public JellyfinModelMapperTests()
{
_responseBuilder = new JellyfinResponseBuilder();
var mockLogger = new Mock<ILogger<JellyfinModelMapper>>();
_mapper = new JellyfinModelMapper(_responseBuilder, mockLogger.Object);
}
[Fact]
public void ParseItemsResponse_AudioItems_ReturnsSongs()
{
// Arrange
var json = @"{
""Items"": [
{
""Id"": ""song-abc"",
""Name"": ""Test Song"",
""Type"": ""Audio"",
""Album"": ""Test Album"",
""AlbumId"": ""album-123"",
""RunTimeTicks"": 2450000000,
""IndexNumber"": 5,
""ParentIndexNumber"": 1,
""ProductionYear"": 2022,
""Artists"": [""Test Artist""],
""Genres"": [""Rock""]
}
],
""TotalRecordCount"": 1
}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(doc);
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
var song = songs[0];
Assert.Equal("song-abc", song.Id);
Assert.Equal("Test Song", song.Title);
Assert.Equal("Test Album", song.Album);
Assert.Equal("Test Artist", song.Artist);
Assert.Equal(245, song.Duration); // 2450000000 ticks = 245 seconds
Assert.Equal(5, song.Track);
Assert.Equal(1, song.DiscNumber);
Assert.Equal(2022, song.Year);
Assert.Equal("Rock", song.Genre);
}
[Fact]
public void ParseItemsResponse_AlbumItems_ReturnsAlbums()
{
// Arrange
var json = @"{
""Items"": [
{
""Id"": ""album-xyz"",
""Name"": ""Greatest Hits"",
""Type"": ""MusicAlbum"",
""AlbumArtist"": ""Famous Band"",
""ProductionYear"": 2020,
""ChildCount"": 14,
""Genres"": [""Pop""],
""AlbumArtists"": [{""Id"": ""artist-1"", ""Name"": ""Famous Band""}]
}
]
}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(doc);
// Assert
Assert.Empty(songs);
Assert.Single(albums);
Assert.Empty(artists);
var album = albums[0];
Assert.Equal("album-xyz", album.Id);
Assert.Equal("Greatest Hits", album.Title);
Assert.Equal("Famous Band", album.Artist);
Assert.Equal(2020, album.Year);
Assert.Equal(14, album.SongCount);
Assert.Equal("Pop", album.Genre);
}
[Fact]
public void ParseItemsResponse_ArtistItems_ReturnsArtists()
{
// Arrange
var json = @"{
""Items"": [
{
""Id"": ""artist-999"",
""Name"": ""The Rockers"",
""Type"": ""MusicArtist"",
""AlbumCount"": 7
}
]
}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(doc);
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Single(artists);
var artist = artists[0];
Assert.Equal("artist-999", artist.Id);
Assert.Equal("The Rockers", artist.Name);
Assert.Equal(7, artist.AlbumCount);
}
[Fact]
public void ParseItemsResponse_MixedTypes_SortsCorrectly()
{
// Arrange
var json = @"{
""Items"": [
{""Id"": ""1"", ""Name"": ""Song"", ""Type"": ""Audio""},
{""Id"": ""2"", ""Name"": ""Album"", ""Type"": ""MusicAlbum""},
{""Id"": ""3"", ""Name"": ""Artist"", ""Type"": ""MusicArtist""},
{""Id"": ""4"", ""Name"": ""Another Song"", ""Type"": ""Audio""}
]
}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(doc);
// Assert
Assert.Equal(2, songs.Count);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseItemsResponse_NullResponse_ReturnsEmptyLists()
{
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(null);
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseItemsResponse_EmptyItems_ReturnsEmptyLists()
{
// Arrange
var json = @"{""Items"": [], ""TotalRecordCount"": 0}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseItemsResponse(doc);
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSong_ExtractsArtistFromAlbumArtist_WhenNoArtistsArray()
{
// Arrange
var json = @"{
""Id"": ""s1"",
""Name"": ""Track"",
""AlbumArtist"": ""Fallback Artist""
}";
var element = JsonDocument.Parse(json).RootElement;
// Act
var song = _mapper.ParseSong(element);
// Assert
Assert.Equal("Fallback Artist", song.Artist);
}
[Fact]
public void ParseSong_ExtractsArtistId_FromArtistItems()
{
// Arrange
var json = @"{
""Id"": ""s1"",
""Name"": ""Track"",
""Artists"": [""Main Artist""],
""ArtistItems"": [{""Id"": ""art-id-123"", ""Name"": ""Main Artist""}]
}";
var element = JsonDocument.Parse(json).RootElement;
// Act
var song = _mapper.ParseSong(element);
// Assert
Assert.Equal("art-id-123", song.ArtistId);
Assert.Equal("Main Artist", song.Artist);
}
[Fact]
public void ParseAlbum_ExtractsArtistId_FromAlbumArtists()
{
// Arrange
var json = @"{
""Id"": ""alb-1"",
""Name"": ""The Album"",
""AlbumArtist"": ""Band Name"",
""AlbumArtists"": [{""Id"": ""band-id"", ""Name"": ""Band Name""}]
}";
var element = JsonDocument.Parse(json).RootElement;
// Act
var album = _mapper.ParseAlbum(element);
// Assert
Assert.Equal("band-id", album.ArtistId);
}
[Fact]
public void MergeSearchResults_DeduplicatesArtistsByName()
{
// Arrange
var localArtists = new List<Artist>
{
new() { Id = "local-1", Name = "The Beatles", IsLocal = true }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new() { Id = "ext-deezer-artist-1", Name = "The Beatles", IsLocal = false },
new() { Id = "ext-deezer-artist-2", Name = "Pink Floyd", IsLocal = false }
}
};
var playlists = new List<ExternalPlaylist>();
// Act
var (songs, albums, artists) = _mapper.MergeSearchResults(
new List<Song>(), new List<Album>(), localArtists, externalResult, playlists);
// Assert - Beatles should not be duplicated, Pink Floyd should be added
Assert.Equal(2, artists.Count);
Assert.Contains(artists, a => a["Id"]!.ToString() == "local-1");
Assert.Contains(artists, a => a["Id"]!.ToString() == "ext-deezer-artist-2");
}
[Fact]
public void MergeSearchResults_IncludesPlaylistsAsAlbums()
{
// Arrange
var playlists = new List<ExternalPlaylist>
{
new() { Id = "pl-1", Name = "Summer Mix", Provider = "deezer", ExternalId = "123" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (songs, albums, artists) = _mapper.MergeSearchResults(
new List<Song>(), new List<Album>(), new List<Artist>(), externalResult, playlists);
// Assert
Assert.Single(albums);
Assert.Equal("pl-1", albums[0]["Id"]);
}
[Fact]
public void ParseAlbumWithTracks_CombinesAlbumAndTracks()
{
// Arrange
var albumJson = @"{
""Id"": ""album-1"",
""Name"": ""Test Album"",
""Type"": ""MusicAlbum"",
""AlbumArtist"": ""Test Artist""
}";
var tracksJson = @"{
""Items"": [
{""Id"": ""t1"", ""Name"": ""Track 1"", ""Type"": ""Audio""},
{""Id"": ""t2"", ""Name"": ""Track 2"", ""Type"": ""Audio""}
]
}";
var albumDoc = JsonDocument.Parse(albumJson);
var tracksDoc = JsonDocument.Parse(tracksJson);
// Act
var album = _mapper.ParseAlbumWithTracks(albumDoc, tracksDoc);
// Assert
Assert.NotNull(album);
Assert.Equal("album-1", album.Id);
Assert.Equal(2, album.Songs.Count);
}
[Fact]
public void ParseAlbumWithTracks_NullAlbum_ReturnsNull()
{
// Act
var album = _mapper.ParseAlbumWithTracks(null, null);
// Assert
Assert.Null(album);
}
[Fact]
public void ParseArtistWithAlbums_SetsAlbumCount()
{
// Arrange
var artistJson = @"{
""Id"": ""art-1"",
""Name"": ""Test Artist"",
""Type"": ""MusicArtist""
}";
var albumsJson = @"{
""Items"": [
{""Id"": ""a1"", ""Name"": ""Album 1""},
{""Id"": ""a2"", ""Name"": ""Album 2""},
{""Id"": ""a3"", ""Name"": ""Album 3""}
]
}";
var artistDoc = JsonDocument.Parse(artistJson);
var albumsDoc = JsonDocument.Parse(albumsJson);
// Act
var artist = _mapper.ParseArtistWithAlbums(artistDoc, albumsDoc);
// Assert
Assert.NotNull(artist);
Assert.Equal("art-1", artist.Id);
Assert.Equal(3, artist.AlbumCount);
}
[Fact]
public void ParseSearchHintsResponse_HandlesSearchHintsFormat()
{
// Arrange
var json = @"{
""SearchHints"": [
{""Id"": ""s1"", ""Name"": ""Song"", ""Type"": ""Audio"", ""Album"": ""Album"", ""AlbumArtist"": ""Artist""},
{""Id"": ""a1"", ""Name"": ""Album"", ""Type"": ""MusicAlbum"", ""AlbumArtist"": ""Artist""},
{""Id"": ""ar1"", ""Name"": ""Artist"", ""Type"": ""MusicArtist""}
],
""TotalRecordCount"": 3
}";
var doc = JsonDocument.Parse(json);
// Act
var (songs, albums, artists) = _mapper.ParseSearchHintsResponse(doc);
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchHintsResponse_NullResponse_ReturnsEmptyLists()
{
// Act
var (songs, albums, artists) = _mapper.ParseSearchHintsResponse(null);
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
}

View File

@@ -0,0 +1,434 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using allstarr.Models.Settings;
using allstarr.Services.Jellyfin;
using System.Net;
using System.Text.Json;
namespace allstarr.Tests;
public class JellyfinProxyServiceTests
{
private readonly JellyfinProxyService _service;
private readonly Mock<HttpMessageHandler> _mockHandler;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly JellyfinSettings _settings;
public JellyfinProxyServiceTests()
{
_mockHandler = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_mockHandler.Object);
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
_settings = new JellyfinSettings
{
Url = "http://localhost:8096",
ApiKey = "test-api-key-12345",
UserId = "user-guid-here",
ClientName = "TestClient",
DeviceName = "TestDevice",
DeviceId = "test-device-id",
ClientVersion = "1.0.0"
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
mockLogger.Object);
}
[Fact]
public async Task GetJsonAsync_ValidResponse_ReturnsJsonDocument()
{
// Arrange
var jsonResponse = "{\"Items\":[{\"Id\":\"123\",\"Name\":\"Test Song\"}],\"TotalRecordCount\":1}";
SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json");
// Act
var result = await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(result);
Assert.True(result.RootElement.TryGetProperty("Items", out var items));
Assert.Equal(1, items.GetArrayLength());
}
[Fact]
public async Task GetJsonAsync_ServerError_ReturnsNull()
{
// Arrange
SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain");
// Act
var result = await _service.GetJsonAsync("Items");
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetJsonAsync_IncludesAuthHeader()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
// Act
await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.Contains("Authorization"));
var authHeader = captured.Headers.GetValues("Authorization").First();
Assert.Contains("MediaBrowser", authHeader);
Assert.Contains(_settings.ApiKey, authHeader);
Assert.Contains(_settings.ClientName, authHeader);
}
[Fact]
public async Task GetBytesAsync_ReturnsBodyAndContentType()
{
// Arrange
var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(imageBytes)
};
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
var (body, contentType) = await _service.GetBytesAsync("Items/123/Images/Primary");
// Assert
Assert.Equal(imageBytes, body);
Assert.Equal("image/png", contentType);
}
[Fact]
public async Task GetBytesSafeAsync_OnError_ReturnsSuccessFalse()
{
// Arrange
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var (body, contentType, success) = await _service.GetBytesSafeAsync("Items/123/Images/Primary");
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task SearchAsync_BuildsCorrectQueryParams()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
});
// Act
await _service.SearchAsync("test query", new[] { "Audio", "MusicAlbum" }, 25);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
Assert.Contains("searchTerm=test%20query", url);
Assert.Contains("includeItemTypes=Audio%2CMusicAlbum", url);
Assert.Contains("limit=25", url);
Assert.Contains("recursive=true", url);
}
[Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint()
{
// Arrange
HttpRequestMessage? captured = null;
var itemJson = "{\"Id\":\"abc-123\",\"Name\":\"My Song\",\"Type\":\"Audio\"}";
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(itemJson)
});
// Act
var result = await _service.GetItemAsync("abc-123");
// Assert
Assert.NotNull(captured);
Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString());
Assert.NotNull(result);
}
[Fact]
public async Task GetArtistsAsync_WithSearchTerm_IncludesInQuery()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
});
// Act
await _service.GetArtistsAsync("Beatles", 10);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
Assert.Contains("/Artists", url);
Assert.Contains("searchTerm=Beatles", url);
Assert.Contains("limit=10", url);
}
[Fact]
public async Task GetImageAsync_WithDimensions_IncludesMaxWidthHeight()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
});
// Act
await _service.GetImageAsync("item-123", "Primary", maxWidth: 300, maxHeight: 300);
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
Assert.Contains("/Items/item-123/Images/Primary", url);
Assert.Contains("maxWidth=300", url);
Assert.Contains("maxHeight=300", url);
}
[Fact]
public async Task MarkFavoriteAsync_PostsToCorrectEndpoint()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
var result = await _service.MarkFavoriteAsync("song-456");
// Assert
Assert.True(result);
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Post, captured!.Method);
Assert.Contains($"/Users/{_settings.UserId}/FavoriteItems/song-456", captured.RequestUri!.ToString());
}
[Fact]
public async Task MarkFavoriteAsync_WithoutUserId_ReturnsFalse()
{
// Arrange - create service without UserId
var settingsWithoutUser = new JellyfinSettings
{
Url = "http://localhost:8096",
ApiKey = "test-key",
UserId = "" // no user
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(settingsWithoutUser),
httpContextAccessor,
mockLogger.Object);
// Act
var result = await service.MarkFavoriteAsync("song-456");
// Assert
Assert.False(result);
}
[Fact]
public async Task TestConnectionAsync_ValidServer_ReturnsSuccess()
{
// Arrange
var serverInfo = "{\"ServerName\":\"My Jellyfin\",\"Version\":\"10.8.0\"}";
SetupMockResponse(HttpStatusCode.OK, serverInfo, "application/json");
// Act
var (success, serverName, version) = await _service.TestConnectionAsync();
// Assert
Assert.True(success);
Assert.Equal("My Jellyfin", serverName);
Assert.Equal("10.8.0", version);
}
[Fact]
public async Task TestConnectionAsync_ServerDown_ReturnsFalse()
{
// Arrange
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var (success, serverName, version) = await _service.TestConnectionAsync();
// Assert
Assert.False(success);
Assert.Null(serverName);
Assert.Null(version);
}
[Fact]
public async Task GetMusicLibraryIdAsync_WhenConfigured_ReturnsConfiguredId()
{
// Arrange - settings already have LibraryId set
var settingsWithLibrary = new JellyfinSettings
{
Url = "http://localhost:8096",
ApiKey = "test-key",
LibraryId = "configured-library-id"
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(settingsWithLibrary),
httpContextAccessor,
mockLogger.Object);
// Act
var result = await service.GetMusicLibraryIdAsync();
// Assert
Assert.Equal("configured-library-id", result);
}
[Fact]
public async Task GetMusicLibraryIdAsync_AutoDetects_MusicLibrary()
{
// Arrange
var librariesJson = "{\"Items\":[{\"Id\":\"video-lib\",\"CollectionType\":\"movies\"},{\"Id\":\"music-lib-123\",\"CollectionType\":\"music\"}]}";
SetupMockResponse(HttpStatusCode.OK, librariesJson, "application/json");
var settingsNoLibrary = new JellyfinSettings
{
Url = "http://localhost:8096",
ApiKey = "test-key",
LibraryId = "" // not configured
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(settingsNoLibrary),
httpContextAccessor,
mockLogger.Object);
// Act
var result = await service.GetMusicLibraryIdAsync();
// Assert
Assert.Equal("music-lib-123", result);
}
[Fact]
public async Task StreamAudioAsync_NullContext_ReturnsError()
{
// Arrange
var httpContextAccessor = new HttpContextAccessor { HttpContext = null };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
mockLogger.Object);
// Act
var result = await service.StreamAudioAsync("song-123", CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
private void SetupMockResponse(HttpStatusCode statusCode, string content, string contentType)
{
var response = new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
};
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
}

View File

@@ -0,0 +1,292 @@
using Microsoft.AspNetCore.Mvc;
using allstarr.Models.Domain;
using allstarr.Models.Subsonic;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
public class JellyfinResponseBuilderTests
{
private readonly JellyfinResponseBuilder _builder;
public JellyfinResponseBuilderTests()
{
_builder = new JellyfinResponseBuilder();
}
[Fact]
public void ConvertSongToJellyfinItem_SetsCorrectFields()
{
// Arrange
var song = new Song
{
Id = "song-123",
Title = "Test Track",
Artist = "Test Artist",
Album = "Test Album",
AlbumId = "album-456",
ArtistId = "artist-789",
Duration = 245,
Track = 3,
DiscNumber = 1,
Year = 2023,
Genre = "Rock",
IsLocal = true
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert
Assert.Equal("song-123", result["Id"]);
Assert.Equal("Test Track", result["Name"]);
Assert.Equal("Audio", result["Type"]);
Assert.Equal("Test Album", result["Album"]);
Assert.Equal("album-456", result["AlbumId"]);
Assert.Equal(3, result["IndexNumber"]);
Assert.Equal(1, result["ParentIndexNumber"]);
Assert.Equal(2023, result["ProductionYear"]);
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
}
[Fact]
public void ConvertSongToJellyfinItem_ExternalSong_IncludesProviderIds()
{
// Arrange
var song = new Song
{
Id = "ext-deezer-song-12345",
Title = "External Track",
Artist = "External Artist",
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = "12345",
Isrc = "USRC12345678"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert
Assert.True(result.ContainsKey("ProviderIds"));
var providerIds = result["ProviderIds"] as Dictionary<string, string>;
Assert.NotNull(providerIds);
Assert.Equal("12345", providerIds["deezer"]);
Assert.Equal("USRC12345678", providerIds["ISRC"]);
}
[Fact]
public void ConvertAlbumToJellyfinItem_SetsCorrectFields()
{
// Arrange
var album = new Album
{
Id = "album-456",
Title = "Greatest Hits",
Artist = "Famous Band",
ArtistId = "artist-123",
Year = 2020,
SongCount = 12,
Genre = "Pop",
IsLocal = true
};
// Act
var result = _builder.ConvertAlbumToJellyfinItem(album);
// Assert
Assert.Equal("album-456", result["Id"]);
Assert.Equal("Greatest Hits", result["Name"]);
Assert.Equal("MusicAlbum", result["Type"]);
Assert.Equal(true, result["IsFolder"]);
Assert.Equal("Famous Band", result["AlbumArtist"]);
Assert.Equal(2020, result["ProductionYear"]);
Assert.Equal(12, result["ChildCount"]);
}
[Fact]
public void ConvertArtistToJellyfinItem_SetsCorrectFields()
{
// Arrange
var artist = new Artist
{
Id = "artist-789",
Name = "The Rockers",
AlbumCount = 5,
IsLocal = true
};
// Act
var result = _builder.ConvertArtistToJellyfinItem(artist);
// Assert
Assert.Equal("artist-789", result["Id"]);
Assert.Equal("The Rockers", result["Name"]);
Assert.Equal("MusicArtist", result["Type"]);
Assert.Equal(true, result["IsFolder"]);
Assert.Equal(5, result["AlbumCount"]);
}
[Fact]
public void ConvertPlaylistToAlbumItem_SetsPlaylistType()
{
// Arrange
var playlist = new ExternalPlaylist
{
Id = "ext-playlist-deezer-999",
ExternalId = "999",
Name = "Summer Vibes",
Provider = "deezer",
CuratorName = "DJ Cool",
TrackCount = 50,
Duration = 3600,
CreatedDate = new DateTime(2023, 6, 15)
};
// Act
var result = _builder.ConvertPlaylistToAlbumItem(playlist);
// Assert
Assert.Equal("ext-playlist-deezer-999", result["Id"]);
Assert.Equal("Summer Vibes", result["Name"]);
Assert.Equal("Playlist", result["Type"]);
Assert.Equal("DJ Cool", result["AlbumArtist"]);
Assert.Equal(50, result["ChildCount"]);
Assert.Equal(2023, result["ProductionYear"]);
}
[Fact]
public void ConvertPlaylistToAlbumItem_NoCurator_UsesProvider()
{
// Arrange
var playlist = new ExternalPlaylist
{
Id = "ext-playlist-deezer-888",
ExternalId = "888",
Name = "Top Hits",
Provider = "deezer",
CuratorName = null,
TrackCount = 30
};
// Act
var result = _builder.ConvertPlaylistToAlbumItem(playlist);
// Assert
Assert.Equal("deezer", result["AlbumArtist"]);
}
[Fact]
public void CreateItemsResponse_ReturnsPaginatedResult()
{
// Arrange
var songs = new List<Song>
{
new() { Id = "1", Title = "Song One", Artist = "Artist", Duration = 200 },
new() { Id = "2", Title = "Song Two", Artist = "Artist", Duration = 180 }
};
// Act
var result = _builder.CreateItemsResponse(songs);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
}
[Fact]
public void CreateSearchHintsResponse_IncludesAllTypes()
{
// Arrange
var songs = new List<Song> { new() { Id = "s1", Title = "Track", Artist = "A" } };
var albums = new List<Album> { new() { Id = "a1", Title = "Album", Artist = "A" } };
var artists = new List<Artist> { new() { Id = "ar1", Name = "Artist" } };
// Act
var result = _builder.CreateSearchHintsResponse(songs, albums, artists);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
}
[Fact]
public void CreateError_Returns404ForNotFound()
{
// Act
var result = _builder.CreateError(404, "Item not found");
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(404, objectResult.StatusCode);
}
[Fact]
public void CreateAlbumResponse_IncludesChildrenForSongs()
{
// Arrange
var album = new Album
{
Id = "album-1",
Title = "Full Album",
Artist = "Artist",
Songs = new List<Song>
{
new() { Id = "t1", Title = "Track 1", Artist = "Artist", Track = 1 },
new() { Id = "t2", Title = "Track 2", Artist = "Artist", Track = 2 }
}
};
// Act
var result = _builder.CreateAlbumResponse(album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
}
[Fact]
public void CreateArtistResponse_IncludesAlbumsList()
{
// Arrange
var artist = new Artist { Id = "art-1", Name = "Test Artist" };
var albums = new List<Album>
{
new() { Id = "alb-1", Title = "First Album", Artist = "Test Artist" },
new() { Id = "alb-2", Title = "Second Album", Artist = "Test Artist" }
};
// Act
var result = _builder.CreateArtistResponse(artist, albums);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
}
[Fact]
public void CreatePlaylistAsAlbumResponse_CalculatesTotalDuration()
{
// Arrange
var playlist = new ExternalPlaylist
{
Id = "pl-1",
Name = "My Playlist",
Provider = "deezer",
ExternalId = "123"
};
var tracks = new List<Song>
{
new() { Id = "t1", Title = "Song 1", Duration = 180 },
new() { Id = "t2", Title = "Song 2", Duration = 240 },
new() { Id = "t3", Title = "Song 3", Duration = 200 }
};
// Act
var result = _builder.CreatePlaylistAsAlbumResponse(playlist, tracks);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
}
}

View File

@@ -0,0 +1,248 @@
using allstarr.Services.Local;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using System.Net;
namespace allstarr.Tests;
public class LocalLibraryServiceTests : IDisposable
{
private readonly LocalLibraryService _service;
private readonly string _testDownloadPath;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
public LocalLibraryServiceTests()
{
_testDownloadPath = Path.Combine(Path.GetTempPath(), "allstarr-tests-" + Guid.NewGuid());
Directory.CreateDirectory(_testDownloadPath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath
})
.Build();
// Mock HttpClient
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"subsonic-response\":{\"status\":\"ok\",\"scanStatus\":{\"scanning\":false,\"count\":100}}}")
});
var httpClient = new HttpClient(mockHandler.Object);
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
var subsonicSettings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" });
var mockLogger = new Mock<ILogger<LocalLibraryService>>();
_service = new LocalLibraryService(configuration, _mockHttpClientFactory.Object, subsonicSettings, mockLogger.Object);
}
public void Dispose()
{
if (Directory.Exists(_testDownloadPath))
{
Directory.Delete(_testDownloadPath, true);
}
}
[Fact]
public void ParseSongId_WithExternalId_ReturnsCorrectParts()
{
// Act
var (isExternal, provider, externalId) = _service.ParseSongId("ext-deezer-123456");
// Assert
Assert.True(isExternal);
Assert.Equal("deezer", provider);
Assert.Equal("123456", externalId);
}
[Fact]
public void ParseSongId_WithLocalId_ReturnsNotExternal()
{
// Act
var (isExternal, provider, externalId) = _service.ParseSongId("local-789");
// Assert
Assert.False(isExternal);
Assert.Null(provider);
Assert.Null(externalId);
}
[Fact]
public void ParseSongId_WithNumericId_ReturnsNotExternal()
{
// Act
var (isExternal, provider, externalId) = _service.ParseSongId("12345");
// Assert
Assert.False(isExternal);
Assert.Null(provider);
Assert.Null(externalId);
}
[Fact]
public async Task GetLocalPathForExternalSongAsync_WhenNotRegistered_ReturnsNull()
{
// Act
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "nonexistent");
// Assert
Assert.Null(result);
}
[Fact]
public async Task RegisterDownloadedSongAsync_ThenGetLocalPath_ReturnsPath()
{
// Arrange
var song = new Song
{
Id = "ext-deezer-123456",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
ExternalProvider = "deezer",
ExternalId = "123456"
};
var localPath = Path.Combine(_testDownloadPath, "test-song.mp3");
// Create the file
await File.WriteAllTextAsync(localPath, "fake audio content");
// Act
await _service.RegisterDownloadedSongAsync(song, localPath);
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "123456");
// Assert
Assert.Equal(localPath, result);
}
[Fact]
public async Task GetLocalPathForExternalSongAsync_WhenFileDeleted_ReturnsNull()
{
// Arrange
var song = new Song
{
Id = "ext-deezer-999999",
Title = "Deleted Song",
Artist = "Test Artist",
Album = "Test Album",
ExternalProvider = "deezer",
ExternalId = "999999"
};
var localPath = Path.Combine(_testDownloadPath, "deleted-song.mp3");
// Create and then delete the file
await File.WriteAllTextAsync(localPath, "fake audio content");
await _service.RegisterDownloadedSongAsync(song, localPath);
File.Delete(localPath);
// Act
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "999999");
// Assert
Assert.Null(result);
}
[Fact]
public async Task RegisterDownloadedSongAsync_WithNullProvider_DoesNothing()
{
// Arrange
var song = new Song
{
Id = "local-123",
Title = "Local Song",
Artist = "Local Artist",
Album = "Local Album",
ExternalProvider = null,
ExternalId = null
};
var localPath = Path.Combine(_testDownloadPath, "local-song.mp3");
// Act - should not throw
await _service.RegisterDownloadedSongAsync(song, localPath);
// Assert - nothing to assert, just checking it doesn't throw
Assert.True(true);
}
[Fact]
public async Task TriggerLibraryScanAsync_ReturnsTrue()
{
// Act
var result = await _service.TriggerLibraryScanAsync();
// Assert
Assert.True(result);
}
[Fact]
public async Task GetScanStatusAsync_ReturnsScanStatus()
{
// Act
var result = await _service.GetScanStatusAsync();
// Assert
Assert.NotNull(result);
Assert.False(result.Scanning);
Assert.Equal(100, result.Count);
}
[Theory]
[InlineData("ext-deezer-123", true, "deezer", "123")]
[InlineData("ext-spotify-abc123", true, "spotify", "abc123")]
[InlineData("ext-tidal-999-888", true, "tidal", "999-888")]
[InlineData("ext-deezer-song-123456", true, "deezer", "123456")] // New format - extracts numeric ID
[InlineData("123456", false, null, null)]
[InlineData("", false, null, null)]
[InlineData("ext-", false, null, null)]
[InlineData("ext-deezer", false, null, null)]
public void ParseSongId_VariousInputs_ReturnsExpected(string songId, bool expectedIsExternal, string? expectedProvider, string? expectedExternalId)
{
// Act
var (isExternal, provider, externalId) = _service.ParseSongId(songId);
// Assert
Assert.Equal(expectedIsExternal, isExternal);
Assert.Equal(expectedProvider, provider);
Assert.Equal(expectedExternalId, externalId);
}
[Theory]
[InlineData("ext-deezer-song-123456", true, "deezer", "song", "123456")]
[InlineData("ext-deezer-album-789012", true, "deezer", "album", "789012")]
[InlineData("ext-deezer-artist-259", true, "deezer", "artist", "259")]
[InlineData("ext-spotify-song-abc123", true, "spotify", "song", "abc123")]
[InlineData("ext-deezer-123", true, "deezer", "song", "123")] // Legacy format defaults to song
[InlineData("ext-tidal-999", true, "tidal", "song", "999")] // Legacy format defaults to song
[InlineData("123456", false, null, null, null)]
[InlineData("", false, null, null, null)]
[InlineData("ext-", false, null, null, null)]
[InlineData("ext-deezer", false, null, null, null)]
public void ParseExternalId_VariousInputs_ReturnsExpected(string id, bool expectedIsExternal, string? expectedProvider, string? expectedType, string? expectedExternalId)
{
// Act
var (isExternal, provider, type, externalId) = _service.ParseExternalId(id);
// Assert
Assert.Equal(expectedIsExternal, isExternal);
Assert.Equal(expectedProvider, provider);
Assert.Equal(expectedType, type);
Assert.Equal(expectedExternalId, externalId);
}
}

View File

@@ -0,0 +1,375 @@
using allstarr.Services.Common;
using Xunit;
namespace allstarr.Tests;
public class PlaylistIdHelperTests
{
#region IsExternalPlaylist Tests
[Fact]
public void IsExternalPlaylist_WithValidPlaylistId_ReturnsTrue()
{
// Arrange
var id = "pl-deezer-123456";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.True(result);
}
[Fact]
public void IsExternalPlaylist_WithValidQobuzPlaylistId_ReturnsTrue()
{
// Arrange
var id = "pl-qobuz-789012";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.True(result);
}
[Fact]
public void IsExternalPlaylist_WithUpperCasePrefix_ReturnsTrue()
{
// Arrange
var id = "PL-deezer-123456";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.True(result);
}
[Fact]
public void IsExternalPlaylist_WithRegularAlbumId_ReturnsFalse()
{
// Arrange
var id = "ext-deezer-album-123456";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.False(result);
}
[Fact]
public void IsExternalPlaylist_WithNullId_ReturnsFalse()
{
// Arrange
string? id = null;
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.False(result);
}
[Fact]
public void IsExternalPlaylist_WithEmptyString_ReturnsFalse()
{
// Arrange
var id = "";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.False(result);
}
[Fact]
public void IsExternalPlaylist_WithRandomString_ReturnsFalse()
{
// Arrange
var id = "random-string-123";
// Act
var result = PlaylistIdHelper.IsExternalPlaylist(id);
// Assert
Assert.False(result);
}
#endregion
#region ParsePlaylistId Tests
[Fact]
public void ParsePlaylistId_WithValidDeezerPlaylistId_ReturnsProviderAndExternalId()
{
// Arrange
var id = "pl-deezer-123456";
// Act
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
// Assert
Assert.Equal("deezer", provider);
Assert.Equal("123456", externalId);
}
[Fact]
public void ParsePlaylistId_WithValidQobuzPlaylistId_ReturnsProviderAndExternalId()
{
// Arrange
var id = "pl-qobuz-789012";
// Act
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
// Assert
Assert.Equal("qobuz", provider);
Assert.Equal("789012", externalId);
}
[Fact]
public void ParsePlaylistId_WithExternalIdContainingDashes_ParsesCorrectly()
{
// Arrange
var id = "pl-deezer-abc-def-123";
// Act
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
// Assert
Assert.Equal("deezer", provider);
Assert.Equal("abc-def-123", externalId);
}
[Fact]
public void ParsePlaylistId_WithInvalidFormatNoProvider_ThrowsArgumentException()
{
// Arrange
var id = "pl-123456";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
Assert.Contains("Invalid playlist ID format", exception.Message);
}
[Fact]
public void ParsePlaylistId_WithNonPlaylistId_ThrowsArgumentException()
{
// Arrange
var id = "ext-deezer-album-123456";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
Assert.Contains("Invalid playlist ID format", exception.Message);
}
[Fact]
public void ParsePlaylistId_WithNullId_ThrowsArgumentException()
{
// Arrange
string? id = null;
// Act & Assert
Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id!));
}
[Fact]
public void ParsePlaylistId_WithEmptyString_ThrowsArgumentException()
{
// Arrange
var id = "";
// Act & Assert
Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
}
[Fact]
public void ParsePlaylistId_WithOnlyPrefix_ThrowsArgumentException()
{
// Arrange
var id = "pl-";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
Assert.Contains("Invalid playlist ID format", exception.Message);
}
#endregion
#region CreatePlaylistId Tests
[Fact]
public void CreatePlaylistId_WithValidDeezerProviderAndId_ReturnsCorrectFormat()
{
// Arrange
var provider = "deezer";
var externalId = "123456";
// Act
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
// Assert
Assert.Equal("pl-deezer-123456", result);
}
[Fact]
public void CreatePlaylistId_WithValidQobuzProviderAndId_ReturnsCorrectFormat()
{
// Arrange
var provider = "qobuz";
var externalId = "789012";
// Act
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
// Assert
Assert.Equal("pl-qobuz-789012", result);
}
[Fact]
public void CreatePlaylistId_WithUpperCaseProvider_ConvertsToLowerCase()
{
// Arrange
var provider = "DEEZER";
var externalId = "123456";
// Act
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
// Assert
Assert.Equal("pl-deezer-123456", result);
}
[Fact]
public void CreatePlaylistId_WithMixedCaseProvider_ConvertsToLowerCase()
{
// Arrange
var provider = "DeEzEr";
var externalId = "123456";
// Act
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
// Assert
Assert.Equal("pl-deezer-123456", result);
}
[Fact]
public void CreatePlaylistId_WithExternalIdContainingDashes_PreservesDashes()
{
// Arrange
var provider = "deezer";
var externalId = "abc-def-123";
// Act
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
// Assert
Assert.Equal("pl-deezer-abc-def-123", result);
}
[Fact]
public void CreatePlaylistId_WithNullProvider_ThrowsArgumentException()
{
// Arrange
string? provider = null;
var externalId = "123456";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider!, externalId));
Assert.Contains("Provider cannot be null or empty", exception.Message);
}
[Fact]
public void CreatePlaylistId_WithEmptyProvider_ThrowsArgumentException()
{
// Arrange
var provider = "";
var externalId = "123456";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId));
Assert.Contains("Provider cannot be null or empty", exception.Message);
}
[Fact]
public void CreatePlaylistId_WithNullExternalId_ThrowsArgumentException()
{
// Arrange
var provider = "deezer";
string? externalId = null;
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId!));
Assert.Contains("External ID cannot be null or empty", exception.Message);
}
[Fact]
public void CreatePlaylistId_WithEmptyExternalId_ThrowsArgumentException()
{
// Arrange
var provider = "deezer";
var externalId = "";
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId));
Assert.Contains("External ID cannot be null or empty", exception.Message);
}
#endregion
#region Round-Trip Tests
[Fact]
public void RoundTrip_CreateAndParse_ReturnsOriginalValues()
{
// Arrange
var originalProvider = "deezer";
var originalExternalId = "123456";
// Act
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
// Assert
Assert.Equal(originalProvider, parsedProvider);
Assert.Equal(originalExternalId, parsedExternalId);
}
[Fact]
public void RoundTrip_CreateWithUpperCaseAndParse_ReturnsLowerCaseProvider()
{
// Arrange
var originalProvider = "QOBUZ";
var originalExternalId = "789012";
// Act
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
// Assert
Assert.Equal("qobuz", parsedProvider); // Converted to lowercase
Assert.Equal(originalExternalId, parsedExternalId);
}
[Fact]
public void RoundTrip_WithComplexExternalId_PreservesValue()
{
// Arrange
var originalProvider = "deezer";
var originalExternalId = "abc-123-def-456";
// Act
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
// Assert
Assert.Equal(originalProvider, parsedProvider);
Assert.Equal(originalExternalId, parsedExternalId);
}
#endregion
}

View File

@@ -0,0 +1,389 @@
using allstarr.Services;
using allstarr.Services.Qobuz;
using allstarr.Services.Local;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Subsonic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using System.Net;
namespace allstarr.Tests;
public class QobuzDownloadServiceTests : IDisposable
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock;
private readonly Mock<IMusicMetadataService> _metadataServiceMock;
private readonly Mock<ILogger<QobuzBundleService>> _bundleServiceLoggerMock;
private readonly Mock<ILogger<QobuzDownloadService>> _loggerMock;
private readonly IConfiguration _configuration;
private readonly string _testDownloadPath;
private QobuzBundleService _bundleService;
public QobuzDownloadServiceTests()
{
_testDownloadPath = Path.Combine(Path.GetTempPath(), "allstarr-qobuz-tests-" + Guid.NewGuid());
Directory.CreateDirectory(_testDownloadPath);
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
_localLibraryServiceMock = new Mock<ILocalLibraryService>();
_metadataServiceMock = new Mock<IMusicMetadataService>();
_bundleServiceLoggerMock = new Mock<ILogger<QobuzBundleService>>();
_loggerMock = new Mock<ILogger<QobuzDownloadService>>();
// Create a real QobuzBundleService for testing (it will use the mocked HttpClient)
_bundleService = new QobuzBundleService(_httpClientFactoryMock.Object, _bundleServiceLoggerMock.Object);
_configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath
})
.Build();
}
public void Dispose()
{
if (Directory.Exists(_testDownloadPath))
{
Directory.Delete(_testDownloadPath, true);
}
}
private QobuzDownloadService CreateService(
string? userAuthToken = null,
string? userId = null,
string? quality = null,
DownloadMode downloadMode = DownloadMode.Track)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath
})
.Build();
var subsonicSettings = Options.Create(new SubsonicSettings
{
DownloadMode = downloadMode
});
var qobuzSettings = Options.Create(new QobuzSettings
{
UserAuthToken = userAuthToken,
UserId = userId,
Quality = quality
});
var serviceProviderMock = new Mock<IServiceProvider>();
serviceProviderMock.Setup(sp => sp.GetService(typeof(allstarr.Services.Subsonic.PlaylistSyncService)))
.Returns(null);
return new QobuzDownloadService(
_httpClientFactoryMock.Object,
config,
_localLibraryServiceMock.Object,
_metadataServiceMock.Object,
_bundleService,
subsonicSettings,
qobuzSettings,
serviceProviderMock.Object,
_loggerMock.Object);
}
#region IsAvailableAsync Tests
[Fact]
public async Task IsAvailableAsync_WithoutUserAuthToken_ReturnsFalse()
{
// Arrange
var service = CreateService(userAuthToken: null, userId: "123");
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_WithoutUserId_ReturnsFalse()
{
// Arrange
var service = CreateService(userAuthToken: "test-token", userId: null);
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_WithEmptyCredentials_ReturnsFalse()
{
// Arrange
var service = CreateService(userAuthToken: "", userId: "");
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_WithValidCredentials_WhenBundleServiceWorks_ReturnsTrue()
{
// Arrange
// Mock a successful response for bundle service
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"<html><script src=""/resources/1.0.0-b001/bundle.js""></script></html>")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString().Contains("qobuz.com")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act
var result = await service.IsAvailableAsync();
// Assert - Will be false because bundle extraction will fail with our mock, but service is constructed
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_WhenBundleServiceFails_ReturnsFalse()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.ServiceUnavailable
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act
var result = await service.IsAvailableAsync();
// Assert
Assert.False(result);
}
#endregion
#region DownloadSongAsync Tests
[Fact]
public async Task DownloadSongAsync_WithUnsupportedProvider_ThrowsNotSupportedException()
{
// Arrange
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(() =>
service.DownloadSongAsync("spotify", "123456"));
}
[Fact]
public async Task DownloadSongAsync_WhenAlreadyDownloaded_ReturnsExistingPath()
{
// Arrange
var existingPath = Path.Combine(_testDownloadPath, "existing-song.flac");
await File.WriteAllTextAsync(existingPath, "fake audio content");
_localLibraryServiceMock
.Setup(s => s.GetLocalPathForExternalSongAsync("qobuz", "123456"))
.ReturnsAsync(existingPath);
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act
var result = await service.DownloadSongAsync("qobuz", "123456");
// Assert
Assert.Equal(existingPath, result);
}
[Fact]
public async Task DownloadSongAsync_WhenSongNotFound_ThrowsException()
{
// Arrange
_localLibraryServiceMock
.Setup(s => s.GetLocalPathForExternalSongAsync("qobuz", "999999"))
.ReturnsAsync((string?)null);
_metadataServiceMock
.Setup(s => s.GetSongAsync("qobuz", "999999"))
.ReturnsAsync((Song?)null);
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(() =>
service.DownloadSongAsync("qobuz", "999999"));
Assert.Equal("Song not found", exception.Message);
}
#endregion
#region GetDownloadStatus Tests
[Fact]
public void GetDownloadStatus_WithUnknownSongId_ReturnsNull()
{
// Arrange
var service = CreateService(userAuthToken: "test-token", userId: "123");
// Act
var result = service.GetDownloadStatus("unknown-id");
// Assert
Assert.Null(result);
}
#endregion
#region Album Download Tests
[Fact]
public void DownloadRemainingAlbumTracksInBackground_WithUnsupportedProvider_DoesNotThrow()
{
// Arrange
var service = CreateService(
userAuthToken: "test-token",
userId: "123",
downloadMode: DownloadMode.Album);
// Act & Assert - Should not throw, just log warning
service.DownloadRemainingAlbumTracksInBackground("spotify", "123456", "789");
}
[Fact]
public void DownloadRemainingAlbumTracksInBackground_WithQobuzProvider_StartsBackgroundTask()
{
// Arrange
_metadataServiceMock
.Setup(s => s.GetAlbumAsync("qobuz", "123456"))
.ReturnsAsync(new Album
{
Id = "ext-qobuz-album-123456",
Title = "Test Album",
Songs = new List<Song>
{
new Song { ExternalId = "111", Title = "Track 1" },
new Song { ExternalId = "222", Title = "Track 2" }
}
});
var service = CreateService(
userAuthToken: "test-token",
userId: "123",
downloadMode: DownloadMode.Album);
// Act - Should not throw (fire-and-forget)
service.DownloadRemainingAlbumTracksInBackground("qobuz", "123456", "111");
// Assert - Just verify it doesn't throw, actual download is async
Assert.True(true);
}
#endregion
#region ExtractExternalIdFromAlbumId Tests
[Fact]
public void ExtractExternalIdFromAlbumId_WithValidQobuzAlbumId_ReturnsExternalId()
{
// Arrange
var service = CreateService(userAuthToken: "test-token", userId: "123");
var albumId = "ext-qobuz-album-0060253780838";
// Act
// We need to use reflection to test this protected method, or test it indirectly
// For now, we'll test it indirectly through DownloadRemainingAlbumTracksInBackground
_metadataServiceMock
.Setup(s => s.GetAlbumAsync("qobuz", "0060253780838"))
.ReturnsAsync(new Album
{
Id = albumId,
Title = "Test Album",
Songs = new List<Song>()
});
// Assert - If this doesn't throw, the extraction worked
service.DownloadRemainingAlbumTracksInBackground("qobuz", albumId, "track-1");
Assert.True(true);
}
#endregion
#region Quality Format Tests
[Fact]
public async Task CreateService_WithFlacQuality_UsesCorrectFormat()
{
// Arrange & Act
var service = CreateService(
userAuthToken: "test-token",
userId: "123",
quality: "FLAC");
// Assert - Service created successfully with quality setting
Assert.NotNull(service);
}
[Fact]
public async Task CreateService_WithMp3Quality_UsesCorrectFormat()
{
// Arrange & Act
var service = CreateService(
userAuthToken: "test-token",
userId: "123",
quality: "MP3_320");
// Assert - Service created successfully with quality setting
Assert.NotNull(service);
}
[Fact]
public async Task CreateService_WithNullQuality_UsesDefaultFormat()
{
// Arrange & Act
var service = CreateService(
userAuthToken: "test-token",
userId: "123",
quality: null);
// Assert - Service created successfully with default quality
Assert.NotNull(service);
}
#endregion
}

View File

@@ -0,0 +1,662 @@
using allstarr.Services.Qobuz;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Subsonic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using System.Net;
namespace allstarr.Tests;
public class QobuzMetadataServiceTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly Mock<QobuzBundleService> _bundleServiceMock;
private readonly Mock<ILogger<QobuzMetadataService>> _loggerMock;
private readonly QobuzMetadataService _service;
public QobuzMetadataServiceTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
// Mock QobuzBundleService (methods are now virtual so can be mocked)
var bundleHttpClientFactoryMock = new Mock<IHttpClientFactory>();
bundleHttpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var bundleLogger = Mock.Of<ILogger<QobuzBundleService>>();
_bundleServiceMock = new Mock<QobuzBundleService>(bundleHttpClientFactoryMock.Object, bundleLogger) { CallBase = false };
_bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345");
_bundleServiceMock.Setup(b => b.GetSecretsAsync()).ReturnsAsync(new List<string> { "fake-secret" });
_bundleServiceMock.Setup(b => b.GetSecretAsync(It.IsAny<int>())).ReturnsAsync("fake-secret");
_loggerMock = new Mock<ILogger<QobuzMetadataService>>();
var subsonicSettings = Options.Create(new SubsonicSettings());
var qobuzSettings = Options.Create(new QobuzSettings
{
UserAuthToken = "fake-user-auth-token",
UserId = "8807208"
});
_service = new QobuzMetadataService(
_httpClientFactoryMock.Object,
subsonicSettings,
qobuzSettings,
_bundleServiceMock.Object,
_loggerMock.Object);
}
#region SearchPlaylistsAsync Tests
[Fact]
public async Task SearchPlaylistsAsync_WithValidQuery_ReturnsPlaylists()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""playlists"": {
""items"": [
{
""id"": 1578664,
""name"": ""Jazz Classics"",
""description"": ""Best of classic jazz music"",
""tracks_count"": 50,
""duration"": 12000,
""owner"": {
""name"": ""Qobuz Editorial""
},
""created_at"": 1609459200,
""images300"": [""https://example.com/cover.jpg""]
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.SearchPlaylistsAsync("jazz", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Jazz Classics", result[0].Name);
Assert.Equal("Best of classic jazz music", result[0].Description);
Assert.Equal(50, result[0].TrackCount);
Assert.Equal(12000, result[0].Duration);
Assert.Equal("qobuz", result[0].Provider);
Assert.Equal("1578664", result[0].ExternalId);
Assert.Equal("pl-qobuz-1578664", result[0].Id);
Assert.Equal("Qobuz Editorial", result[0].CuratorName);
}
[Fact]
public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""playlists"": {
""items"": []
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.SearchPlaylistsAsync("nonexistent", 20);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task SearchPlaylistsAsync_WhenHttpFails_ReturnsEmptyList()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.InternalServerError
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.SearchPlaylistsAsync("jazz", 20);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
#endregion
#region GetPlaylistAsync Tests
[Fact]
public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""id"": 1578664,
""name"": ""Best Of Jazz"",
""description"": ""Top jazz tracks"",
""tracks_count"": 100,
""duration"": 24000,
""owner"": {
""name"": ""Qobuz Editor""
},
""created_at"": 1609459200,
""image_rectangle"": [""https://example.com/cover-large.jpg""]
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetPlaylistAsync("qobuz", "1578664");
// Assert
Assert.NotNull(result);
Assert.Equal("Best Of Jazz", result.Name);
Assert.Equal("Top jazz tracks", result.Description);
Assert.Equal(100, result.TrackCount);
Assert.Equal(24000, result.Duration);
Assert.Equal("pl-qobuz-1578664", result.Id);
Assert.Equal("Qobuz Editor", result.CuratorName);
Assert.Equal("https://example.com/cover-large.jpg", result.CoverUrl);
}
[Fact]
public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull()
{
// Act
var result = await _service.GetPlaylistAsync("deezer", "12345");
// Assert
Assert.Null(result);
}
#endregion
#region GetPlaylistTracksAsync Tests
[Fact]
public async Task GetPlaylistTracksAsync_WithValidId_ReturnsTracks()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""id"": 1578664,
""name"": ""My Jazz Playlist"",
""tracks"": {
""items"": [
{
""id"": 123456789,
""title"": ""Take Five"",
""duration"": 324,
""track_number"": 1,
""media_number"": 1,
""performer"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""album"": {
""id"": 222,
""title"": ""Time Out"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""image"": {
""thumbnail"": ""https://example.com/time-out.jpg""
}
}
},
{
""id"": 987654321,
""title"": ""So What"",
""duration"": 562,
""track_number"": 2,
""media_number"": 1,
""performer"": {
""id"": 333,
""name"": ""Miles Davis""
},
""album"": {
""id"": 444,
""title"": ""Kind of Blue"",
""artist"": {
""id"": 333,
""name"": ""Miles Davis""
},
""image"": {
""thumbnail"": ""https://example.com/kind-of-blue.jpg""
}
}
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664");
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
// First track
Assert.Equal("Take Five", result[0].Title);
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
Assert.Equal("My Jazz Playlist", result[0].Album); // Album should be playlist name
Assert.Equal(1, result[0].Track); // Track index starts at 1
Assert.Equal("ext-qobuz-song-123456789", result[0].Id);
Assert.Equal("qobuz", result[0].ExternalProvider);
Assert.Equal("123456789", result[0].ExternalId);
// Second track
Assert.Equal("So What", result[1].Title);
Assert.Equal("Miles Davis", result[1].Artist);
Assert.Equal("My Jazz Playlist", result[1].Album); // Album should be playlist name
Assert.Equal(2, result[1].Track); // Track index increments
Assert.Equal("ext-qobuz-song-987654321", result[1].Id);
}
[Fact]
public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList()
{
// Act
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetPlaylistTracksAsync_WhenHttpFails_ReturnsEmptyList()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetPlaylistTracksAsync("qobuz", "999999");
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetPlaylistTracksAsync_WithMissingPlaylistName_UsesDefaultName()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""id"": 1578664,
""tracks"": {
""items"": [
{
""id"": 123,
""title"": ""Test Track"",
""performer"": {
""id"": 1,
""name"": ""Test Artist""
},
""album"": {
""id"": 2,
""title"": ""Test Album"",
""artist"": {
""id"": 1,
""name"": ""Test Artist""
}
}
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664");
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Unknown Playlist", result[0].Album);
}
#endregion
#region SearchSongsAsync Tests
[Fact]
public async Task SearchSongsAsync_WithValidQuery_ReturnsSongs()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""tracks"": {
""items"": [
{
""id"": 123456789,
""title"": ""Take Five"",
""duration"": 324,
""track_number"": 1,
""performer"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""album"": {
""id"": 222,
""title"": ""Time Out"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
}
}
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.SearchSongsAsync("Take Five", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Take Five", result[0].Title);
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
}
#endregion
#region SearchAlbumsAsync Tests
[Fact]
public async Task SearchAlbumsAsync_WithValidQuery_ReturnsAlbums()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""albums"": {
""items"": [
{
""id"": 222,
""title"": ""Time Out"",
""tracks_count"": 7,
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""release_date_original"": ""1959-12-14""
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.SearchAlbumsAsync("Time Out", 20);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Time Out", result[0].Title);
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
Assert.Equal(1959, result[0].Year);
}
#endregion
#region GetSongAsync Tests
[Fact]
public async Task GetSongAsync_WithValidId_ReturnsSong()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""id"": 123456789,
""title"": ""Take Five"",
""duration"": 324,
""track_number"": 1,
""isrc"": ""USCO10300456"",
""copyright"": ""(P) 1959 Columbia Records"",
""performer"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""composer"": {
""id"": 999,
""name"": ""Paul Desmond""
},
""album"": {
""id"": 222,
""title"": ""Time Out"",
""tracks_count"": 7,
""release_date_original"": ""1959-12-14"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""genres_list"": [""Jazz"", ""Jazz→Cool Jazz""]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetSongAsync("qobuz", "123456789");
// Assert
Assert.NotNull(result);
Assert.Equal("Take Five", result.Title);
Assert.Equal("Dave Brubeck Quartet", result.Artist);
Assert.Equal("Time Out", result.Album);
Assert.Equal("USCO10300456", result.Isrc);
Assert.Equal("℗ 1959 Columbia Records", result.Copyright);
Assert.Equal(1959, result.Year);
Assert.Equal("1959-12-14", result.ReleaseDate);
Assert.Contains("Paul Desmond", result.Contributors);
Assert.Equal("Jazz, Cool Jazz", result.Genre);
}
[Fact]
public async Task GetSongAsync_WithWrongProvider_ReturnsNull()
{
// Act
var result = await _service.GetSongAsync("deezer", "123456789");
// Assert
Assert.Null(result);
}
#endregion
#region GetAlbumAsync Tests
[Fact]
public async Task GetAlbumAsync_WithValidId_ReturnsAlbumWithTracks()
{
// Arrange
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{
""id"": 222,
""title"": ""Time Out"",
""tracks_count"": 2,
""release_date_original"": ""1959-12-14"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""genres_list"": [""Jazz""],
""tracks"": {
""items"": [
{
""id"": 1,
""title"": ""Blue Rondo à la Turk"",
""track_number"": 1,
""performer"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""album"": {
""id"": 222,
""title"": ""Time Out"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
}
}
},
{
""id"": 2,
""title"": ""Take Five"",
""track_number"": 2,
""performer"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
},
""album"": {
""id"": 222,
""title"": ""Time Out"",
""artist"": {
""id"": 111,
""name"": ""Dave Brubeck Quartet""
}
}
}
]
}
}")
};
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(mockResponse);
// Act
var result = await _service.GetAlbumAsync("qobuz", "222");
// Assert
Assert.NotNull(result);
Assert.Equal("Time Out", result.Title);
Assert.Equal("Dave Brubeck Quartet", result.Artist);
Assert.Equal(1959, result.Year);
Assert.Equal(2, result.Songs.Count);
Assert.Equal("Blue Rondo à la Turk", result.Songs[0].Title);
Assert.Equal("Take Five", result.Songs[1].Title);
}
[Fact]
public async Task GetAlbumAsync_WithWrongProvider_ReturnsNull()
{
// Act
var result = await _service.GetAlbumAsync("deezer", "222");
// Assert
Assert.Null(result);
}
#endregion
}

View File

@@ -0,0 +1,321 @@
using Microsoft.Extensions.Logging;
using Moq;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Subsonic;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
namespace allstarr.Tests;
public class SubsonicModelMapperTests
{
private readonly SubsonicModelMapper _mapper;
private readonly Mock<ILogger<SubsonicModelMapper>> _mockLogger;
private readonly SubsonicResponseBuilder _responseBuilder;
public SubsonicModelMapperTests()
{
_responseBuilder = new SubsonicResponseBuilder();
_mockLogger = new Mock<ILogger<SubsonicModelMapper>>();
_mapper = new SubsonicModelMapper(_responseBuilder, _mockLogger.Object);
}
[Fact]
public void ParseSearchResponse_JsonWithSongs_ParsesCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{
""id"": ""song1"",
""title"": ""Test Song"",
""artist"": ""Test Artist"",
""album"": ""Test Album""
}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithSongs_ParsesCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Test Song"" artist=""Test Artist"" album=""Test Album"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_JsonWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{""id"": ""song1"", ""title"": ""Song 1""}
],
""album"": [
{""id"": ""album1"", ""name"": ""Album 1""}
],
""artist"": [
{""id"": ""artist1"", ""name"": ""Artist 1""}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Song 1"" />
<album id=""album1"" name=""Album 1"" />
<artist id=""artist1"" name=""Artist 1"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_InvalidJson_ReturnsEmpty()
{
// Arrange
var invalidJson = "{invalid json}";
var responseBody = Encoding.UTF8.GetBytes(invalidJson);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_EmptySearchResult_ReturnsEmpty()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void MergeSearchResults_Json_MergesSongsCorrectly()
{
// Arrange
var localSongs = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["title"] = "Local Song" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), true);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Json_CaseInsensitiveDeduplication()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "test artist" } // Different case - should still be filtered
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), true);
// Assert
Assert.Single(mergedArtists); // Only the local artist
}
[Fact]
public void MergeSearchResults_Xml_MergesSongsCorrectly()
{
// Arrange
var ns = XNamespace.Get("http://subsonic.org/restapi");
var localSongs = new List<object>
{
new XElement("song", new XAttribute("id", "local1"), new XAttribute("title", "Local Song"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), false);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Xml_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new XElement("artist", new XAttribute("id", "local1"), new XAttribute("name", "Test Artist"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), false);
// Assert
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
}
[Fact]
public void MergeSearchResults_EmptyLocalResults_ReturnsOnlyExternal()
{
// Arrange
var externalResult = new SearchResult
{
Songs = new List<Song> { new Song { Id = "ext1" } },
Albums = new List<Album> { new Album { Id = "ext2" } },
Artists = new List<Artist> { new Artist { Id = "ext3", Name = "Artist" } }
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
[Fact]
public void MergeSearchResults_EmptyExternalResults_ReturnsOnlyLocal()
{
// Arrange
var localSongs = new List<object> { new Dictionary<string, object> { ["id"] = "local1" } };
var localAlbums = new List<object> { new Dictionary<string, object> { ["id"] = "local2" } };
var localArtists = new List<object> { new Dictionary<string, object> { ["id"] = "local3", ["name"] = "Local" } };
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, localAlbums, localArtists, externalResult, new List<ExternalPlaylist>(), true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
}

View File

@@ -0,0 +1,423 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http;
using Moq;
using Moq.Protected;
using allstarr.Models.Settings;
using allstarr.Services.Subsonic;
using System.Net;
namespace allstarr.Tests;
public class SubsonicProxyServiceTests
{
private readonly SubsonicProxyService _service;
private readonly Mock<HttpMessageHandler> _mockHttpMessageHandler;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
public SubsonicProxyServiceTests()
{
_mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_mockHttpMessageHandler.Object);
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
var settings = Options.Create(new SubsonicSettings
{
Url = "http://localhost:4533"
});
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor
{
HttpContext = httpContext
};
_service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor);
}
[Fact]
public async Task RelayAsync_SuccessfulRequest_ReturnsBodyAndContentType()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "password" },
{ "v", "1.16.0" }
};
// Act
var (body, contentType) = await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.Equal(responseContent, body);
Assert.Equal("application/json", contentType);
}
[Fact]
public async Task RelayAsync_BuildsCorrectUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "secret" }
};
// Act
await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.NotNull(capturedRequest);
Assert.Contains("http://localhost:4533/rest/ping", capturedRequest!.RequestUri!.ToString());
Assert.Contains("u=admin", capturedRequest.RequestUri.ToString());
Assert.Contains("p=secret", capturedRequest.RequestUri.ToString());
}
[Fact]
public async Task RelayAsync_EncodesSpecialCharacters()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "query", "rock & roll" },
{ "artist", "AC/DC" }
};
// Act
await _service.RelayAsync("rest/search3", parameters);
// Assert
Assert.NotNull(capturedRequest);
var url = capturedRequest!.RequestUri!.ToString();
// HttpClient automatically applies URL encoding when building the URI
// Space can be encoded as + or %20, & as %26, / as %2F
Assert.Contains("query=", url);
Assert.Contains("artist=", url);
Assert.Contains("AC%2FDC", url); // / should be encoded as %2F
}
[Fact]
public async Task RelayAsync_HttpError_ThrowsException()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(() =>
_service.RelayAsync("rest/ping", parameters));
}
[Fact]
public async Task RelaySafeAsync_SuccessfulRequest_ReturnsSuccessTrue()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/xml");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.True(success);
Assert.Equal(responseContent, body);
Assert.Equal("application/xml", contentType);
}
[Fact]
public async Task RelaySafeAsync_HttpError_ReturnsSuccessFalse()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.InternalServerError);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelaySafeAsync_NetworkException_ReturnsSuccessFalse()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelayStreamAsync_SuccessfulRequest_ReturnsFileStreamResult()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "id", "song123" },
{ "u", "admin" }
};
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
Assert.True(fileResult.EnableRangeProcessing);
}
[Fact]
public async Task RelayStreamAsync_HttpError_ReturnsStatusCodeResult()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var statusResult = Assert.IsType<StatusCodeResult>(result);
Assert.Equal(404, statusResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_Exception_ReturnsObjectResultWith500()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection failed"));
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_DefaultContentType_UsesAudioMpeg()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
// No ContentType set
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
}
[Fact]
public async Task RelayStreamAsync_WithRangeHeader_ForwardsRangeToUpstream()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var streamContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(streamContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Range"] = "bytes=0-1023";
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var service = new SubsonicProxyService(_mockHttpClientFactory.Object,
Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }),
httpContextAccessor);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
await service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
Assert.NotNull(capturedRequest);
Assert.True(capturedRequest!.Headers.Contains("Range"));
Assert.Equal("bytes=0-1023", capturedRequest.Headers.GetValues("Range").First());
}
[Fact]
public async Task RelayStreamAsync_WithIfRangeHeader_ForwardsIfRangeToUpstream()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var streamContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["If-Range"] = "\"etag123\"";
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var service = new SubsonicProxyService(_mockHttpClientFactory.Object,
Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }),
httpContextAccessor);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
await service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
Assert.NotNull(capturedRequest);
Assert.True(capturedRequest!.Headers.Contains("If-Range"));
}
[Fact]
public async Task RelayStreamAsync_NullHttpContext_ReturnsError()
{
// Arrange
var httpContextAccessor = new HttpContextAccessor { HttpContext = null };
var service = new SubsonicProxyService(_mockHttpClientFactory.Object,
Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }),
httpContextAccessor);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
}

View File

@@ -0,0 +1,202 @@
using Microsoft.AspNetCore.Http;
using allstarr.Services.Subsonic;
using System.Text;
namespace allstarr.Tests;
public class SubsonicRequestParserTests
{
private readonly SubsonicRequestParser _parser;
public SubsonicRequestParserTests()
{
_parser = new SubsonicRequestParser();
}
[Fact]
public async Task ExtractAllParametersAsync_QueryParameters_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&v=1.16.0&c=testclient&f=json");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("1.16.0", result["v"]);
Assert.Equal("testclient", result["c"]);
Assert.Equal("json", result["f"]);
}
[Fact]
public async Task ExtractAllParametersAsync_FormEncodedBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var formData = "u=admin&p=password&query=test+artist&artistCount=10";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_JsonBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":\"password\",\"query\":\"test artist\",\"artistCount\":10}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_QueryAndFormBody_MergesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&f=json");
var formData = "query=test&artistCount=5";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("json", result["f"]);
Assert.Equal("test", result["query"]);
Assert.Equal("5", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_EmptyRequest_ReturnsEmptyDictionary()
{
// Arrange
var context = new DefaultHttpContext();
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task ExtractAllParametersAsync_SpecialCharacters_EncodesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?query=rock+%26+roll&artist=AC%2FDC");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("rock & roll", result["query"]);
Assert.Equal("AC/DC", result["artist"]);
}
[Fact]
public async Task ExtractAllParametersAsync_InvalidJson_IgnoresBody()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin");
var invalidJson = "{invalid json}";
var bytes = Encoding.UTF8.GetBytes(invalidJson);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Single(result);
Assert.Equal("admin", result["u"]);
}
[Fact]
public async Task ExtractAllParametersAsync_NullJsonValues_HandlesGracefully()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":null,\"query\":\"test\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("", result["p"]);
Assert.Equal("test", result["query"]);
}
[Fact]
public async Task ExtractAllParametersAsync_DuplicateKeys_BodyOverridesQuery()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?format=xml&query=old");
var jsonData = "{\"query\":\"new\",\"artist\":\"Beatles\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("xml", result["format"]);
Assert.Equal("new", result["query"]); // Body overrides query
Assert.Equal("Beatles", result["artist"]);
}
}

View File

@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Mvc;
using allstarr.Models.Domain;
using allstarr.Services.Subsonic;
using System.Text.Json;
using System.Xml.Linq;
namespace allstarr.Tests;
public class SubsonicResponseBuilderTests
{
private readonly SubsonicResponseBuilder _builder;
public SubsonicResponseBuilderTests()
{
_builder = new SubsonicResponseBuilder();
}
[Fact]
public void CreateResponse_JsonFormat_ReturnsJsonWithOkStatus()
{
// Act
var result = _builder.CreateResponse("json", "testElement", new { });
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
// Serialize and deserialize to check structure
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
Assert.Equal("ok", doc.RootElement.GetProperty("subsonic-response").GetProperty("status").GetString());
Assert.Equal("1.16.1", doc.RootElement.GetProperty("subsonic-response").GetProperty("version").GetString());
}
[Fact]
public void CreateResponse_XmlFormat_ReturnsXmlWithOkStatus()
{
// Act
var result = _builder.CreateResponse("xml", "testElement", new { });
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("subsonic-response", root.Name.LocalName);
Assert.Equal("ok", root.Attribute("status")?.Value);
Assert.Equal("1.16.1", root.Attribute("version")?.Value);
}
[Fact]
public void CreateError_JsonFormat_ReturnsJsonWithError()
{
// Act
var result = _builder.CreateError("json", 70, "Test error message");
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var response = doc.RootElement.GetProperty("subsonic-response");
Assert.Equal("failed", response.GetProperty("status").GetString());
Assert.Equal(70, response.GetProperty("error").GetProperty("code").GetInt32());
Assert.Equal("Test error message", response.GetProperty("error").GetProperty("message").GetString());
}
[Fact]
public void CreateError_XmlFormat_ReturnsXmlWithError()
{
// Act
var result = _builder.CreateError("xml", 70, "Test error message");
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("failed", root.Attribute("status")?.Value);
var ns = root.GetDefaultNamespace();
var errorElement = root.Element(ns + "error");
Assert.NotNull(errorElement);
Assert.Equal("70", errorElement.Attribute("code")?.Value);
Assert.Equal("Test error message", errorElement.Attribute("message")?.Value);
}
[Fact]
public void CreateSongResponse_JsonFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
Track = 5,
Year = 2023,
Genre = "Rock",
LocalPath = "/music/test.mp3"
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
Assert.Equal("Test Artist", songData.GetProperty("artist").GetString());
Assert.Equal("Test Album", songData.GetProperty("album").GetString());
}
[Fact]
public void CreateSongResponse_XmlFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180
};
// Act
var result = _builder.CreateSongResponse("xml", song);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var songElement = doc.Root!.Element(ns + "song");
Assert.NotNull(songElement);
Assert.Equal("song123", songElement.Attribute("id")?.Value);
Assert.Equal("Test Song", songElement.Attribute("title")?.Value);
}
[Fact]
public void CreateAlbumResponse_JsonFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
Year = 2023,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1", Duration = 180 },
new Song { Id = "song2", Title = "Song 2", Duration = 200 }
}
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal("album123", albumData.GetProperty("id").GetString());
Assert.Equal("Test Album", albumData.GetProperty("name").GetString());
Assert.Equal(2, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(380, albumData.GetProperty("duration").GetInt32());
}
[Fact]
public void CreateAlbumResponse_XmlFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
SongCount = 2,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1" },
new Song { Id = "song2", Title = "Song 2" }
}
};
// Act
var result = _builder.CreateAlbumResponse("xml", album);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var albumElement = doc.Root!.Element(ns + "album");
Assert.NotNull(albumElement);
Assert.Equal("album123", albumElement.Attribute("id")?.Value);
Assert.Equal("2", albumElement.Attribute("songCount")?.Value);
}
[Fact]
public void CreateArtistResponse_JsonFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("json", artist, albums);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var artistData = doc.RootElement.GetProperty("subsonic-response").GetProperty("artist");
Assert.Equal("artist123", artistData.GetProperty("id").GetString());
Assert.Equal("Test Artist", artistData.GetProperty("name").GetString());
Assert.Equal(2, artistData.GetProperty("albumCount").GetInt32());
}
[Fact]
public void CreateArtistResponse_XmlFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("xml", artist, albums);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var artistElement = doc.Root!.Element(ns + "artist");
Assert.NotNull(artistElement);
Assert.Equal("artist123", artistElement.Attribute("id")?.Value);
Assert.Equal("Test Artist", artistElement.Attribute("name")?.Value);
Assert.Equal("2", artistElement.Attribute("albumCount")?.Value);
}
[Fact]
public void CreateSongResponse_SongWithNullValues_HandlesGracefully()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song"
// Other fields are null
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
}
[Fact]
public void CreateAlbumResponse_EmptySongList_ReturnsZeroCounts()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Empty Album",
Artist = "Test Artist",
Songs = new List<Song>()
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal(0, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(0, albumData.GetProperty("duration").GetInt32());
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>allstarr.Tests</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\allstarr\allstarr.csproj" />
</ItemGroup>
</Project>