mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
476
allstarr.Tests/DeezerDownloadServiceTests.cs
Normal file
476
allstarr.Tests/DeezerDownloadServiceTests.cs
Normal 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
|
||||
}
|
||||
805
allstarr.Tests/DeezerMetadataServiceTests.cs
Normal file
805
allstarr.Tests/DeezerMetadataServiceTests.cs
Normal 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
|
||||
}
|
||||
401
allstarr.Tests/JellyfinModelMapperTests.cs
Normal file
401
allstarr.Tests/JellyfinModelMapperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
434
allstarr.Tests/JellyfinProxyServiceTests.cs
Normal file
434
allstarr.Tests/JellyfinProxyServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
292
allstarr.Tests/JellyfinResponseBuilderTests.cs
Normal file
292
allstarr.Tests/JellyfinResponseBuilderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
248
allstarr.Tests/LocalLibraryServiceTests.cs
Normal file
248
allstarr.Tests/LocalLibraryServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
375
allstarr.Tests/PlaylistIdHelperTests.cs
Normal file
375
allstarr.Tests/PlaylistIdHelperTests.cs
Normal 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
|
||||
}
|
||||
389
allstarr.Tests/QobuzDownloadServiceTests.cs
Normal file
389
allstarr.Tests/QobuzDownloadServiceTests.cs
Normal 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
|
||||
}
|
||||
662
allstarr.Tests/QobuzMetadataServiceTests.cs
Normal file
662
allstarr.Tests/QobuzMetadataServiceTests.cs
Normal 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
|
||||
}
|
||||
321
allstarr.Tests/SubsonicModelMapperTests.cs
Normal file
321
allstarr.Tests/SubsonicModelMapperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
423
allstarr.Tests/SubsonicProxyServiceTests.cs
Normal file
423
allstarr.Tests/SubsonicProxyServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
202
allstarr.Tests/SubsonicRequestParserTests.cs
Normal file
202
allstarr.Tests/SubsonicRequestParserTests.cs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
322
allstarr.Tests/SubsonicResponseBuilderTests.cs
Normal file
322
allstarr.Tests/SubsonicResponseBuilderTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
28
allstarr.Tests/allstarr.Tests.csproj
Normal file
28
allstarr.Tests/allstarr.Tests.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user