Files
allstarr/allstarr.Tests/Providers/LocalLibraryServiceTests.cs
T

285 lines
11 KiB
C#

using allstarr.Services.Local;
using allstarr.Core.Downloads;
using allstarr.Core.Capabilities;
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,
new TestMemoryDownloadedSongMappingStore(),
mockLogger.Object);
}
public void Dispose()
{
if (Directory.Exists(_testDownloadPath))
{
Directory.Delete(_testDownloadPath, true);
}
}
[Fact]
public void ParseSongId_WithExternalId_ReturnsCorrectParts()
{
var (isExternal, provider, externalId) = _service.ParseSongId("ext-deezer-123456");
Assert.True(isExternal);
Assert.Equal("deezer", provider);
Assert.Equal("123456", externalId);
}
[Fact]
public void ParseSongId_WithLocalId_ReturnsNotExternal()
{
var (isExternal, provider, externalId) = _service.ParseSongId("local-789");
Assert.False(isExternal);
Assert.Null(provider);
Assert.Null(externalId);
}
[Fact]
public void ParseSongId_WithNumericId_ReturnsNotExternal()
{
var (isExternal, provider, externalId) = _service.ParseSongId("12345");
Assert.False(isExternal);
Assert.Null(provider);
Assert.Null(externalId);
}
[Fact]
public async Task GetLocalPathForExternalSongAsync_WhenNotRegistered_ReturnsNull()
{
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "nonexistent");
Assert.Null(result);
}
[Fact]
public async Task RegisterDownloadedSongAsync_ThenGetLocalPath_ReturnsPath()
{
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");
await _service.RegisterDownloadedSongAsync(song, localPath);
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "123456");
Assert.Equal(localPath, result);
}
[Fact]
public async Task ScopedDownloadedSong_IsVisibleOnlyToTheExactPlaybackScope()
{
var tenant = Guid.CreateVersion7();
var account = Guid.CreateVersion7();
var scope = new DownloadedSongMappingScope(
tenant, account, "music", ProviderAudioQuality.Lossless);
var song = new Song
{
Id = "ext-deezer-song-private",
Title = "Scoped song",
Artist = "Artist",
Album = "Album",
ExternalProvider = "deezer",
ExternalId = "private"
};
var localPath = Path.Combine(_testDownloadPath, "scoped.flac");
await File.WriteAllTextAsync(localPath, "audio");
await _service.RegisterDownloadedSongAsync(scope, song, localPath);
Assert.Equal(localPath, await _service.GetLocalPathForExternalSongAsync(
scope, "deezer", "private"));
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
scope with { TenantId = Guid.CreateVersion7() }, "deezer", "private"));
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
scope with { ProviderAccountId = Guid.CreateVersion7() }, "deezer", "private"));
Assert.Null(await _service.GetLocalPathForExternalSongAsync(
scope with { AudioQuality = ProviderAudioQuality.Lossy }, "deezer", "private"));
Assert.Null(await _service.GetLocalPathForExternalSongAsync("deezer", "private"));
}
[Fact]
public async Task GetLocalPathForExternalSongAsync_WhenFileDeleted_ReturnsNull()
{
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);
var result = await _service.GetLocalPathForExternalSongAsync("deezer", "999999");
Assert.Null(result);
}
[Fact]
public async Task TriggerLibraryScanAsync_ReturnsTrue()
{
var result = await _service.TriggerLibraryScanAsync();
Assert.True(result);
}
[Fact]
public async Task GetScanStatusAsync_ReturnsScanStatus()
{
var result = await _service.GetScanStatusAsync();
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)
{
var (isExternal, provider, externalId) = _service.ParseSongId(songId);
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-apple-musickit-album-i.album", true, "apple-musickit", "album", "i.album")]
[InlineData("ext-apple-musickit-song-i-song-1", true, "apple-musickit", "song", "i-song-1")]
[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)
{
var (isExternal, provider, type, externalId) = _service.ParseExternalId(id);
Assert.Equal(expectedIsExternal, isExternal);
Assert.Equal(expectedProvider, provider);
Assert.Equal(expectedType, type);
Assert.Equal(expectedExternalId, externalId);
}
private sealed class TestMemoryDownloadedSongMappingStore : IDownloadedSongMappingStore
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DownloadedSongMappingEntity> _items = new();
public Task<DownloadedSongMappingEntity?> FindAsync(string providerId, string externalId, CancellationToken cancellationToken = default)
{
var key = $"{DownloadedSongMappingScope.LegacyKey}:{providerId}:{externalId}";
_items.TryGetValue(key, out var mapping);
return Task.FromResult(mapping);
}
public Task<DownloadedSongMappingEntity?> FindAsync(
DownloadedSongMappingScope scope,
string providerId,
string externalId,
CancellationToken cancellationToken = default)
{
_items.TryGetValue($"{scope.Key}:{providerId}:{externalId}", out var mapping);
return Task.FromResult(mapping);
}
public Task UpsertAsync(DownloadedSongMappingEntity mapping, CancellationToken cancellationToken = default)
{
var key = $"{mapping.ScopeKey}:{mapping.ProviderId}:{mapping.ExternalId}";
_items[key] = mapping;
return Task.CompletedTask;
}
public Task RemoveAsync(Guid id, long revision, CancellationToken cancellationToken = default)
{
var target = _items.FirstOrDefault(kvp => kvp.Value.Id == id);
if (target.Key != null)
{
_items.TryRemove(target.Key, out _);
}
return Task.CompletedTask;
}
}
}