Design Prot Merge #1
@@ -5,12 +5,16 @@ process them asynchronously (audio transcoded to `.m4a`, thumbnails generated)
|
||||
and stream them without the bytes ever going through the API.
|
||||
|
||||
```
|
||||
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway
|
||||
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
|
||||
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
|
||||
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
|
||||
docs/ What each piece does and why (in Spanish)
|
||||
```
|
||||
|
||||
```sh
|
||||
dotnet test backend/Musify.slnx # backend/Tests/ — no Docker, no database
|
||||
```
|
||||
|
||||
## Run it
|
||||
|
||||
Development (Docker Desktop):
|
||||
|
||||
@@ -8,3 +8,5 @@
|
||||
.claude/
|
||||
**/DesignSettings.json
|
||||
*.user
|
||||
# Test projects: never shipped, no app Dockerfile needs them in its build context.
|
||||
Tests/
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
|
||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
<PackageVersion Include="YouTubeMusicAPI" Version="3.0.7" />
|
||||
|
||||
<!-- Test-only -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<!-- Pins the native SQLite bundle EFCore.Sqlite pulls in transitively: 2.1.11 has
|
||||
a known high-severity advisory (GHSA-2m69-gcr7-jv3q), fixed in 2.1.13. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.13" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,4 +9,8 @@
|
||||
<Project Path="Hosts/Musify.Worker/Musify.Worker.csproj" />
|
||||
<Project Path="Hosts/Musify.StreamingGateway/Musify.StreamingGateway.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj" />
|
||||
<Project Path="Tests/Musify.Application.Tests/Musify.Application.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FirstTrack_AddsItAtNumberOne()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, album, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync());
|
||||
Assert.Equal(1, link.TrackNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, stranger, album, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var otherOwner = TestEntities.User(2, "other");
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var foreignTrack = TestEntities.LocalTrack(otherOwner);
|
||||
await SeedAsync(owner, otherOwner, album, foreignTrack);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
||||
await SeedAsync(owner, album, track, link);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var firstTrack = TestEntities.LocalTrack(owner, "First");
|
||||
var secondTrack = TestEntities.LocalTrack(owner, "Second");
|
||||
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
|
||||
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id);
|
||||
Assert.Equal(4, newLink.TrackNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner);
|
||||
|
||||
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("My Album", result.Value.Title);
|
||||
Assert.Equal("A description", result.Value.Description);
|
||||
Assert.Equal(2024, result.Value.ReleaseYear);
|
||||
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
||||
Assert.Equal(0, result.Value.TrackCount);
|
||||
|
||||
var stored = await Database.UserAlbums.FindAsync(result.Value.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
|
||||
{
|
||||
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(ErrorOr.ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_RemovesAlbum()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Null(await Database.UserAlbums.FindAsync(album.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, stranger, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.NotNull(await Database.UserAlbums.FindAsync(album.Id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(album.Id, result.Value.Id);
|
||||
Assert.Equal(1, result.Value.TrackCount);
|
||||
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var second = TestEntities.LocalTrack(owner, "Second");
|
||||
var first = TestEntities.LocalTrack(owner, "First");
|
||||
await SeedAsync(
|
||||
owner, album, second, first,
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var other = TestEntities.User(2, "other");
|
||||
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var album = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Mine", album.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetAlbumsQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var album = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Rock Classics", album.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Items.Count);
|
||||
Assert.True(result.Value.HasNextPage);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
|
||||
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
|
||||
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
|
||||
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
|
||||
await SeedAsync(
|
||||
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
|
||||
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
|
||||
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
|
||||
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
|
||||
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
|
||||
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
|
||||
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
|
||||
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
|
||||
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
var first = TestEntities.LocalTrack(owner, "First");
|
||||
var second = TestEntities.LocalTrack(owner, "Second");
|
||||
var third = TestEntities.LocalTrack(owner, "Third");
|
||||
await SeedAsync(
|
||||
owner, album, first, second, third,
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var remaining = await Database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == album.Id)
|
||||
.OrderBy(link => link.TrackNumber)
|
||||
.ToListAsync();
|
||||
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
|
||||
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, stranger, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums;
|
||||
|
||||
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("New Title", result.Value.Title);
|
||||
Assert.Equal("New description", result.Value.Description);
|
||||
Assert.Equal(2025, result.Value.ReleaseYear);
|
||||
|
||||
var stored = await Database.UserAlbums.FindAsync(album.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var album = TestEntities.UserAlbum(owner.Id);
|
||||
await SeedAsync(owner, stranger, album);
|
||||
|
||||
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes;
|
||||
|
||||
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private GenerateMixesForUserCommandHandler CreateHandler() => new(
|
||||
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(await Database.Mixes.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(user);
|
||||
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(await Database.Mixes.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
|
||||
{
|
||||
youTubeMusicService
|
||||
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new YouTubeSearchResult(
|
||||
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", false, [])],
|
||||
ContinuationToken: string.Empty));
|
||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||
|
||||
var user = TestEntities.User();
|
||||
var artist = TestEntities.Artist("Seed Artist");
|
||||
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
|
||||
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
|
||||
|
||||
var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
|
||||
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item");
|
||||
|
||||
await SeedAsync(
|
||||
user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem,
|
||||
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
|
||||
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
|
||||
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var mixes = await Database.Mixes.ToListAsync();
|
||||
Assert.NotEmpty(mixes);
|
||||
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
|
||||
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes;
|
||||
|
||||
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetMixByIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var mix = TestEntities.Mix(owner.Id, "Discovery");
|
||||
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
|
||||
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
|
||||
await SeedAsync(owner, mix, second, first);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Discovery", result.Value.Title);
|
||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MixMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var mix = TestEntities.Mix(owner.Id);
|
||||
await SeedAsync(owner, mix);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes;
|
||||
|
||||
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var other = TestEntities.User(2, "other");
|
||||
var second = TestEntities.Mix(owner.Id, "Second", position: 1);
|
||||
var first = TestEntities.Mix(owner.Id, "First", position: 0);
|
||||
var theirs = TestEntities.Mix(other.Id, "Theirs");
|
||||
await SeedAsync(owner, other, second, first, theirs);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoMixes_ReturnsEmptyList()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Musify.Domain\Musify.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Musify.Application\Musify.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FirstTrack_AddsItAtPositionZero()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, playList, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync());
|
||||
Assert.Equal(0, link.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
|
||||
var newTrack = TestEntities.LocalTrack(owner, "New");
|
||||
await SeedAsync(
|
||||
owner, playList, existingTrack, newTrack,
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id);
|
||||
Assert.Equal(1, newLink.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
|
||||
{
|
||||
var provisioner = new YouTubeTrackProvisioner(
|
||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||
|
||||
return new AddYouTubeTrackToPlayListCommandHandler(
|
||||
Database,
|
||||
eventBus,
|
||||
youTubeMusicService,
|
||||
provisioner,
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Track(),
|
||||
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
|
||||
}
|
||||
|
||||
private static YouTubeSongResult BuildSong(string videoId) => new(
|
||||
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
|
||||
[new YouTubeArtistRef("artist-id", "Song Artist")]);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||
await SeedAsync(owner, playList, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Single(await Database.PlayListHasTracks.ToListAsync());
|
||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload()
|
||||
{
|
||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
|
||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Song Title", result.Value.Title);
|
||||
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video");
|
||||
Assert.True(track.Audio.DownloadRequested);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackAlreadyLinked_ReturnsConflict()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
|
||||
private CreatePlayListCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
NoOpLogger<CreatePlayListCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
TestConfigurations.UploadIntent());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("My Playlist", result.Value.Name);
|
||||
Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
|
||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
|
||||
{
|
||||
storageService
|
||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ObjectMetaData("image/webp", 1024));
|
||||
|
||||
var user = TestEntities.User();
|
||||
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
|
||||
await SeedAsync(user, intent);
|
||||
|
||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await Database.PlayLists.FindAsync(result.Value.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PictureIntentNotFound_ReturnsError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
|
||||
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_MarksPlayListAsRemoving()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await Database.PlayLists.FindAsync(playList.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(playList.Name, result.Value.Name);
|
||||
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListCoverQueryHandler CreateHandler() =>
|
||||
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", "small.webp")]
|
||||
[InlineData("large", "large.webp")]
|
||||
[InlineData("medium", "medium.webp")]
|
||||
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var second = TestEntities.LocalTrack(owner, "Second");
|
||||
var first = TestEntities.LocalTrack(owner, "First");
|
||||
await SeedAsync(
|
||||
owner, playList, second, first,
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var other = TestEntities.User(2, "other");
|
||||
await SeedAsync(
|
||||
owner, other,
|
||||
TestEntities.PlayList(owner.Id, "Mine"),
|
||||
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
||||
TestEntities.PlayList(other.Id, "Theirs"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var playList = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Mine", playList.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var playList = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Road Trip", playList.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListsQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryPlayListPaged()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoPlayLists_ReturnsEmptyPage()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value.Items);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInPlayList_RemovesTheLink()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(Database.PlayListHasTracks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotLinked_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
|
||||
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||
{
|
||||
storageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.Returns("https://storage.musify.test/presigned-upload");
|
||||
|
||||
return new RequestPlayListPictureUploadCommandHandler(
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
storageService,
|
||||
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
|
||||
Assert.Single(await Database.UploadIntents.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
|
||||
{
|
||||
var config = TestConfigurations.UploadIntent();
|
||||
config.MaxActiveUploadBytesPerUser = 100;
|
||||
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
|
||||
|
||||
var result = await CreateHandler(config).Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
Assert.Empty(await Database.UploadIntents.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists;
|
||||
|
||||
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
|
||||
private UpdatePlayListCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
NoOpLogger<UpdatePlayListCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
TestConfigurations.UploadIntent());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_RenamesPlayList()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, "Old Name");
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("New Name", result.Value.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Original Name", result.Value.Name);
|
||||
Assert.Equal("Original description", result.Value.Description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Services;
|
||||
|
||||
public sealed class TrackStreamIssuerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||
|
||||
private TrackStreamIssuer CreateIssuer() => new(
|
||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
||||
{
|
||||
ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120));
|
||||
var owner = TestEntities.User(7);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, CancellationToken.None);
|
||||
|
||||
Assert.Equal("signed-token", response.Ticket);
|
||||
Assert.Equal(120, response.ExpiresInSeconds);
|
||||
Assert.Equal(
|
||||
"https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a",
|
||||
response.ManifestUrl);
|
||||
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync());
|
||||
Assert.Equal(7, history.UserId);
|
||||
Assert.Equal(track.Id, history.TrackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
||||
var owner = TestEntities.User(1);
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, CancellationToken.None);
|
||||
|
||||
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Services;
|
||||
|
||||
public sealed class UploadIntentValidatorTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
private readonly Domain.Entities.User owner = TestEntities.User();
|
||||
|
||||
private UploadIntentValidator CreateValidator() => new(Database, storageService);
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
|
||||
{
|
||||
storageService
|
||||
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
|
||||
.Returns(new ObjectMetaData("image/webp", 2048));
|
||||
|
||||
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(intent.Id, result.Value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
|
||||
{
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, stranger, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
|
||||
{
|
||||
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
|
||||
{
|
||||
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
|
||||
{
|
||||
storageService
|
||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((ObjectMetaData?)null);
|
||||
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
|
||||
{
|
||||
var config = TestConfigurations.UploadIntent();
|
||||
config.MaxUploadBytes = 1024;
|
||||
|
||||
storageService
|
||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ObjectMetaData("image/webp", 2048));
|
||||
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
|
||||
{
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
|
||||
{
|
||||
var config = TestConfigurations.UploadIntent();
|
||||
config.MaxActiveUploadIntentsPerUser = 1;
|
||||
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
|
||||
{
|
||||
var config = TestConfigurations.UploadIntent();
|
||||
config.MaxActiveUploadIntentsPerUser = 1;
|
||||
await SeedAsync(
|
||||
owner,
|
||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
|
||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Services;
|
||||
|
||||
public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private YouTubeTrackProvisioner CreateProvisioner() =>
|
||||
new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||
|
||||
[Fact]
|
||||
public async Task FindAsync_UnknownVideo_ReturnsNull()
|
||||
{
|
||||
var track = await CreateProvisioner().FindAsync("missing-video", CancellationToken.None);
|
||||
|
||||
Assert.Null(track);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService()
|
||||
{
|
||||
var existing = TestEntities.ExternalTrack(externalId: "known-video");
|
||||
await SeedAsync(existing);
|
||||
|
||||
var result = await CreateProvisioner().GetOrCreateAsync("known-video", CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(existing.Id, result.Value.Track.Id);
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
|
||||
{
|
||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
|
||||
new YouTubeSongResult(
|
||||
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
|
||||
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
|
||||
|
||||
var result = await CreateProvisioner().GetOrCreateAsync("new-video", CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("New Song", result.Value.Track.Title);
|
||||
Assert.Single(await Database.Artists.ToListAsync());
|
||||
Assert.Single(await Database.TrackArtists.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
|
||||
{
|
||||
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
|
||||
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
|
||||
|
||||
var result = await CreateProvisioner().GetOrCreateAsync("bad-video", CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
|
||||
{
|
||||
var detail = new YouTubeAlbumDetail(
|
||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
|
||||
[new YouTubeArtistRef(null, "Album Artist")]),
|
||||
Description: "desc",
|
||||
TotalDurationSeconds: 400,
|
||||
Tracks:
|
||||
[
|
||||
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
|
||||
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
|
||||
]);
|
||||
|
||||
await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, await Database.ExternalTracks.CountAsync());
|
||||
Assert.Equal(2, await Database.AlbumHasTracks.CountAsync());
|
||||
Assert.Single(await Database.ExternalAlbums.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
|
||||
{
|
||||
var detail = new YouTubeAlbumDetail(
|
||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
||||
Description: "desc",
|
||||
TotalDurationSeconds: 200,
|
||||
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
||||
|
||||
var provisioner = CreateProvisioner();
|
||||
await provisioner.MaterializeAlbumAsync("album-1", detail, CancellationToken.None);
|
||||
await provisioner.MaterializeAlbumAsync("album-1", detail, CancellationToken.None);
|
||||
|
||||
Assert.Single(await Database.ExternalTracks.ToListAsync());
|
||||
Assert.Single(await Database.AlbumHasTracks.ToListAsync());
|
||||
Assert.Single(await Database.ExternalAlbums.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Musify.Application.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Shared;
|
||||
|
||||
public sealed class StorageKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
|
||||
{
|
||||
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
|
||||
|
||||
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
|
||||
{
|
||||
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
|
||||
|
||||
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Combine_BlankSegment_IsSkipped(string? blank)
|
||||
{
|
||||
var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
|
||||
|
||||
Assert.Equal("Tracks/cover.webp", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NoSegments_ReturnsEmptyString()
|
||||
{
|
||||
Assert.Equal(string.Empty, StorageKey.Combine());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Shared;
|
||||
|
||||
public sealed class TrackApplicationResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
|
||||
{
|
||||
var owner = TestEntities.User(1, "The Owner");
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
|
||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
|
||||
|
||||
Assert.Equal("The Owner", response.Artist);
|
||||
Assert.Equal(TrackSource.Local, response.Source);
|
||||
Assert.Equal(owner.Id, response.OwnerUserId);
|
||||
Assert.Equal(3, response.ListensCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder()
|
||||
{
|
||||
var track = TestEntities.ExternalTrack();
|
||||
var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" };
|
||||
var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" };
|
||||
track.TrackArtists =
|
||||
[
|
||||
new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track },
|
||||
new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track }
|
||||
];
|
||||
|
||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||
|
||||
Assert.Equal("First Artist, Second Artist", response.Artist);
|
||||
Assert.Equal(track.ExternalId, response.ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist()
|
||||
{
|
||||
var track = TestEntities.ExternalTrack();
|
||||
|
||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||
|
||||
Assert.Null(response.Artist);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for handler tests: gives every test method its own empty, isolated
|
||||
/// <see cref="TestDatabase"/> (xUnit creates a new instance of the test class per test method,
|
||||
/// so there is no state to reset between tests) and a no-op logger factory for handlers that
|
||||
/// take an <see cref="ILogger{TCategoryName}"/>.
|
||||
/// </summary>
|
||||
public abstract class HandlerTestBase : IAsyncLifetime
|
||||
{
|
||||
private TestDatabase database = null!;
|
||||
|
||||
protected TestDatabase Database => database;
|
||||
|
||||
public async Task InitializeAsync() => database = await TestDatabase.CreateAsync();
|
||||
|
||||
public Task DisposeAsync() => database.DisposeAsync().AsTask();
|
||||
|
||||
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
/// <summary>Adds the entities and persists them, so handlers under test see committed data.</summary>
|
||||
protected async Task SeedAsync(params object[] entities)
|
||||
{
|
||||
foreach (var entity in entities)
|
||||
Database.Add(entity);
|
||||
|
||||
await Database.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration POCOs with every <c>required</c> member filled and the rest left at their
|
||||
/// production defaults, so handler tests don't have to know which fields matter for them.
|
||||
/// </summary>
|
||||
public static class TestConfigurations
|
||||
{
|
||||
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
|
||||
new() { Bucket = bucket };
|
||||
|
||||
public static TrackConfiguration Track() => new();
|
||||
|
||||
public static PlayListConfiguration PlayList() => new();
|
||||
|
||||
public static MixConfiguration Mix() => new();
|
||||
|
||||
public static UploadIntentConfiguration UploadIntent() => new();
|
||||
|
||||
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
|
||||
new() { PublicBaseUrl = publicBaseUrl };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IDatabase"/> backed by a private, in-memory SQLite database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="IDatabase"/> exposes raw <see cref="DbSet{TEntity}"/> properties, so handlers
|
||||
/// compose real LINQ queries (<c>Where</c>, <c>Select</c>, <c>Include</c>, ...) against it.
|
||||
/// Substituting <see cref="IDatabase"/> with a mock cannot express that — there is no
|
||||
/// <see cref="IQueryable{T}"/> provider behind a mock. SQLite's in-memory mode gives us a real
|
||||
/// relational engine instead: fast, no external dependency, and — unlike the EF Core InMemory
|
||||
/// provider — it actually supports transactions, which two handlers rely on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The model mirrors the parts of <c>Musify.Infrastructure.Persistence.Database</c> that affect
|
||||
/// query shape (owned types, table-per-type inheritance, unique indexes). MassTransit outbox/saga
|
||||
/// state and Postgres-specific configuration are left out — the Application layer never touches
|
||||
/// them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TestDatabase : DbContext, IDatabase
|
||||
{
|
||||
private readonly SqliteConnection connection;
|
||||
|
||||
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
|
||||
: base(options)
|
||||
{
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh, empty database with its own private connection. Call <see cref="Seed"/>
|
||||
/// (via <see cref="Seeding.EntitySeedExtensions"/>) to populate it, then <see cref="DisposeAsync"/>
|
||||
/// when the test is done.
|
||||
/// </summary>
|
||||
public static async Task<TestDatabase> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Filename=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<TestDatabase>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var database = new TestDatabase(connection, options);
|
||||
await database.Database.EnsureCreatedAsync();
|
||||
return database;
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures);
|
||||
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
|
||||
|
||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
|
||||
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
|
||||
|
||||
modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks");
|
||||
|
||||
modelBuilder.Entity<LocalTrack>()
|
||||
.HasOne(track => track.Owner)
|
||||
.WithMany()
|
||||
.HasForeignKey(track => track.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks");
|
||||
|
||||
modelBuilder.Entity<ExternalTrack>()
|
||||
.HasIndex(track => new { track.Source, track.ExternalId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TrackArtist>()
|
||||
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
|
||||
|
||||
modelBuilder.Entity<TrackArtist>()
|
||||
.HasOne(trackArtist => trackArtist.Track)
|
||||
.WithMany(track => track.TrackArtists)
|
||||
.HasForeignKey(trackArtist => trackArtist.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TrackArtist>()
|
||||
.HasOne(trackArtist => trackArtist.Artist)
|
||||
.WithMany(artist => artist.TrackArtists)
|
||||
.HasForeignKey(trackArtist => trackArtist.ArtistId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<Artist>()
|
||||
.HasIndex(artist => artist.ExternalId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
|
||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
|
||||
|
||||
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums");
|
||||
|
||||
modelBuilder.Entity<UserAlbum>()
|
||||
.HasOne(album => album.Owner)
|
||||
.WithMany()
|
||||
.HasForeignKey(album => album.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums");
|
||||
|
||||
modelBuilder.Entity<ExternalAlbum>()
|
||||
.HasIndex(album => new { album.Source, album.ExternalId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasOne(albumTrack => albumTrack.Album)
|
||||
.WithMany(album => album.AlbumTracks)
|
||||
.HasForeignKey(albumTrack => albumTrack.AlbumId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasOne(albumTrack => albumTrack.Track)
|
||||
.WithMany(track => track.AlbumTracks)
|
||||
.HasForeignKey(albumTrack => albumTrack.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Mix>()
|
||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasOne(item => item.Mix)
|
||||
.WithMany(mix => mix.Items)
|
||||
.HasForeignKey(item => item.MixId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasOne(item => item.Track)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.TrackId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasIndex(item => new { item.MixId, item.Position });
|
||||
}
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
public DbSet<PlayList> PlayLists => Set<PlayList>();
|
||||
|
||||
public DbSet<Album> Albums => Set<Album>();
|
||||
|
||||
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
|
||||
|
||||
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
|
||||
|
||||
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
||||
|
||||
public DbSet<Track> Tracks => Set<Track>();
|
||||
|
||||
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
|
||||
|
||||
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
|
||||
|
||||
public DbSet<Artist> Artists => Set<Artist>();
|
||||
|
||||
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
|
||||
|
||||
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
||||
|
||||
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
||||
|
||||
public DbSet<Mix> Mixes => Set<Mix>();
|
||||
|
||||
public DbSet<MixItem> MixItems => Set<MixItem>();
|
||||
|
||||
public DbSet<Upload> Uploads => Set<Upload>();
|
||||
|
||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||
|
||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
||||
|
||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
||||
{
|
||||
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
||||
return new SqliteDatabaseTransaction(transaction);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
|
||||
: IDatabaseTransaction
|
||||
{
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default) =>
|
||||
inner.CommitAsync(cancellationToken);
|
||||
|
||||
public ValueTask DisposeAsync() => inner.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Builds fully-populated domain entities for tests, with sensible defaults for every
|
||||
/// <c>required</c> member. Every factory always returns a valid instance — never null — so
|
||||
/// callers never need a null check on what this class hands back.
|
||||
/// </summary>
|
||||
public static class TestEntities
|
||||
{
|
||||
public static User User(
|
||||
long id = 1,
|
||||
string name = "test-user",
|
||||
string? firstName = "Test",
|
||||
string? secondName = "User",
|
||||
string? profilePictureUrl = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
FirstName = firstName,
|
||||
SecondName = secondName,
|
||||
ProfilePictureUrl = profilePictureUrl
|
||||
};
|
||||
|
||||
public static TrackPictures ProcessedPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp",
|
||||
ProcessingStatus = ProcessingStatus.Completed
|
||||
};
|
||||
|
||||
public static TrackPictures PendingPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
|
||||
{
|
||||
OriginalName = "original.mp3",
|
||||
FolderName = folderName,
|
||||
TranscodeStatus = ProcessingStatus.Completed
|
||||
};
|
||||
|
||||
public static TrackAudio PendingAudio() => new()
|
||||
{
|
||||
OriginalName = "original.mp3",
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
public static LocalTrack LocalTrack(
|
||||
User owner,
|
||||
string title = "Test Track",
|
||||
double durationSeconds = 180,
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
DurationSeconds = durationSeconds,
|
||||
LifeCycleStatus = lifeCycleStatus,
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Pictures = pictures ?? ProcessedPictures(),
|
||||
Audio = audio ?? ProcessedAudio()
|
||||
};
|
||||
|
||||
public static ExternalTrack ExternalTrack(
|
||||
string externalId = "video-id",
|
||||
string title = "Test External Track",
|
||||
double durationSeconds = 180,
|
||||
TrackSource source = TrackSource.YouTube,
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
DurationSeconds = durationSeconds,
|
||||
LifeCycleStatus = lifeCycleStatus,
|
||||
Source = source,
|
||||
ExternalId = externalId,
|
||||
Pictures = pictures ?? ProcessedPictures(),
|
||||
Audio = audio ?? ProcessedAudio()
|
||||
};
|
||||
|
||||
public static Artist Artist(
|
||||
string name = "Test Artist",
|
||||
string externalId = "artist-id") =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
ExternalId = externalId
|
||||
};
|
||||
|
||||
public static PlayListPictures PlayListPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp"
|
||||
};
|
||||
|
||||
public static PlayList PlayList(
|
||||
long userId,
|
||||
string name = "Test Playlist",
|
||||
string? description = null,
|
||||
PlayListPictures? pictures = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
Description = description,
|
||||
Pictures = pictures ?? PlayListPictures(),
|
||||
LifeCycleStatus = lifeCycleStatus
|
||||
};
|
||||
|
||||
public static AlbumPictures AlbumPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
MediumName = "medium.webp",
|
||||
LargeName = "large.webp"
|
||||
};
|
||||
|
||||
public static UserAlbum UserAlbum(
|
||||
long ownerUserId,
|
||||
string title = "Test Album",
|
||||
string? description = null,
|
||||
int? releaseYear = 2024,
|
||||
AlbumPictures? pictures = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Description = description,
|
||||
ReleaseYear = releaseYear,
|
||||
OwnerUserId = ownerUserId,
|
||||
Pictures = pictures ?? AlbumPictures()
|
||||
};
|
||||
|
||||
public static ExternalAlbum ExternalAlbum(
|
||||
string externalId = "album-id",
|
||||
string title = "Test External Album",
|
||||
TrackSource source = TrackSource.YouTube,
|
||||
string? thumbnailUrl = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Source = source,
|
||||
ExternalId = externalId,
|
||||
ThumbnailUrl = thumbnailUrl
|
||||
};
|
||||
|
||||
public static UploadIntent UploadIntent(
|
||||
long userId,
|
||||
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
|
||||
string bucket = "test-bucket",
|
||||
string key = "temp/object.webp",
|
||||
string objectName = "object.webp",
|
||||
string contentType = "image/webp",
|
||||
long? expectedSizeBytes = 1024,
|
||||
UploadIntentStatus status = UploadIntentStatus.Issued,
|
||||
DateTime? expiresAt = null) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Bucket = bucket,
|
||||
Key = key,
|
||||
ObjectName = objectName,
|
||||
ContentType = contentType,
|
||||
ExpectedSizeBytes = expectedSizeBytes,
|
||||
Purpose = purpose,
|
||||
Status = status,
|
||||
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
|
||||
};
|
||||
|
||||
public static Mix Mix(
|
||||
long userId,
|
||||
string title = "Test Mix",
|
||||
string? subtitle = null,
|
||||
int position = 0) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Position = position
|
||||
};
|
||||
|
||||
public static MixItem MixItem(
|
||||
Guid mixId,
|
||||
int position = 0,
|
||||
MixItemSource source = MixItemSource.Musify,
|
||||
Guid? trackId = null,
|
||||
string? videoId = null,
|
||||
string title = "Test Mix Item",
|
||||
string? artist = "Test Artist",
|
||||
double durationSeconds = 180,
|
||||
bool isExplicit = false) =>
|
||||
new()
|
||||
{
|
||||
MixId = mixId,
|
||||
Position = position,
|
||||
Source = source,
|
||||
TrackId = trackId,
|
||||
VideoId = videoId,
|
||||
Title = title,
|
||||
Artist = artist,
|
||||
DurationSeconds = durationSeconds,
|
||||
IsExplicit = isExplicit
|
||||
};
|
||||
|
||||
public static ListeningHistory ListeningHistory(
|
||||
long userId,
|
||||
Guid trackId,
|
||||
DateTime? listenedAt = null) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = trackId,
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
|
||||
private CreateTrackCommandHandler CreateHandler()
|
||||
{
|
||||
storageService
|
||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ObjectMetaData("image/webp", 1024));
|
||||
|
||||
return new CreateTrackCommandHandler(
|
||||
Database,
|
||||
eventBus,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
NoOpLogger<CreateTrackCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Track(),
|
||||
TestConfigurations.UploadIntent());
|
||||
}
|
||||
|
||||
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
|
||||
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
|
||||
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("My Song", result.Value.Title);
|
||||
Assert.Equal(user.Id, result.Value.OwnerUserId);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||
|
||||
var stored = await Database.LocalTracks.FindAsync(result.Value.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
|
||||
{
|
||||
var user = TestEntities.User(1, "user");
|
||||
var otherUser = TestEntities.User(2, "other");
|
||||
var (picture, audio) = SeedIntents(otherUser.Id);
|
||||
await SeedAsync(user, otherUser, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
|
||||
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
|
||||
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await Database.Tracks.FindAsync(track.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, stranger, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExternalTrack_ReturnsUnauthorized()
|
||||
{
|
||||
var externalTrack = TestEntities.ExternalTrack();
|
||||
await SeedAsync(externalTrack);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AudioStillProcessing_ReturnsConflict()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var older = TestEntities.LocalTrack(owner, "Older listen");
|
||||
var newer = TestEntities.LocalTrack(owner, "Newer listen");
|
||||
await SeedAsync(
|
||||
owner, older, newer,
|
||||
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
|
||||
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
|
||||
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoHistory_ReturnsEmpty()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTrackByIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
|
||||
{
|
||||
var owner = TestEntities.User(1, "artist-name");
|
||||
var track = TestEntities.LocalTrack(owner, "My Song");
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("My Song", result.Value.Title);
|
||||
Assert.Equal(owner.Name, result.Value.Artist);
|
||||
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTrackCoverQueryHandler CreateHandler() =>
|
||||
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", "small.webp")]
|
||||
[InlineData("medium", "medium.webp")]
|
||||
[InlineData("large", "large.webp")]
|
||||
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
|
||||
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
Assert.Equal("image/webp", result.Value.ContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||
|
||||
private GetTrackStreamQueryHandler CreateHandler()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60));
|
||||
|
||||
var issuer = new TrackStreamIssuer(
|
||||
Database,
|
||||
ticketService,
|
||||
TestConfigurations.Track(),
|
||||
TestConfigurations.StreamGateway("https://stream.musify.test"));
|
||||
|
||||
return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("ticket-token", result.Value.Ticket);
|
||||
Assert.Equal(60, result.Value.ExpiresInSeconds);
|
||||
Assert.Contains("audio-folder", result.Value.ManifestUrl);
|
||||
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync());
|
||||
Assert.Equal(owner.Id, history.UserId);
|
||||
Assert.Equal(track.Id, history.TrackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var other = TestEntities.User(2, "other");
|
||||
var mine = TestEntities.LocalTrack(owner, "Mine");
|
||||
var theirs = TestEntities.LocalTrack(other, "Theirs");
|
||||
await SeedAsync(
|
||||
owner, other, mine, theirs,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var track = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Mine", track.Title);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-5, 1)]
|
||||
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.LocalTrack(owner);
|
||||
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTracksQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var trackA = TestEntities.LocalTrack(owner, "Alpha");
|
||||
var trackB = TestEntities.LocalTrack(owner, "Beta");
|
||||
await SeedAsync(
|
||||
owner, trackA, trackB,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
|
||||
var other = TestEntities.LocalTrack(owner, "Imagine");
|
||||
await SeedAsync(
|
||||
owner, match, other,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var track = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", track.Title);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks;
|
||||
|
||||
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
|
||||
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||
{
|
||||
storageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.Returns("https://storage.musify.test/presigned-upload");
|
||||
|
||||
return new RequestTrackUploadUrlsCommandHandler(
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
storageService,
|
||||
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Track(),
|
||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
|
||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
|
||||
|
||||
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync();
|
||||
Assert.Equal(2, intents.Count);
|
||||
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
|
||||
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
|
||||
{
|
||||
var config = TestConfigurations.UploadIntent();
|
||||
config.MaxActiveUploadIntentsPerUser = 1;
|
||||
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
|
||||
|
||||
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||
|
||||
var result = await CreateHandler(config).Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
Assert.Equal(1, await Database.UploadIntents.CountAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users;
|
||||
|
||||
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NewId_CreatesUser()
|
||||
{
|
||||
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Jane Doe", result.Value.Name);
|
||||
Assert.Equal(1, result.Value.Id);
|
||||
|
||||
var stored = await Database.Users.FindAsync(1L);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("JANE DOE", stored.NormalizedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IdAlreadyExists_ReturnsConflict()
|
||||
{
|
||||
var existing = TestEntities.User(1);
|
||||
await SeedAsync(existing);
|
||||
|
||||
var command = new CreateUserCommand(1, "Someone Else", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users;
|
||||
|
||||
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetUserByIdQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingUser_ReturnsIt()
|
||||
{
|
||||
var user = TestEntities.User(1, "Jane Doe");
|
||||
await SeedAsync(user);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Jane Doe", result.Value.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users;
|
||||
|
||||
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetUsersQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoSearch_ReturnsEveryUser()
|
||||
{
|
||||
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
|
||||
{
|
||||
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var user = Assert.Single(result.Value.Items);
|
||||
Assert.Equal(1, user.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users;
|
||||
|
||||
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UnknownUser_ProvisionsIt()
|
||||
{
|
||||
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await Database.Users.FindAsync(1L);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("Jane Doe", stored.Name);
|
||||
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingUser_UpdatesProfileFields()
|
||||
{
|
||||
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
|
||||
|
||||
var result = await CreateHandler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await Database.Users.FindAsync(1L);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("New Name", stored.Name);
|
||||
Assert.Equal("NEW NAME", stored.NormalizedName);
|
||||
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.YouTube;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.YouTube;
|
||||
|
||||
public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private GetYouTubeAlbumQueryHandler CreateHandler()
|
||||
{
|
||||
var provisioner = new YouTubeTrackProvisioner(
|
||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||
|
||||
return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>());
|
||||
}
|
||||
|
||||
private static YouTubeAlbumDetail BuildDetail() => new(
|
||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
||||
Description: "An album",
|
||||
TotalDurationSeconds: 600,
|
||||
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyAlbumId_ReturnsValidationError()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail()
|
||||
{
|
||||
var detail = BuildDetail();
|
||||
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Same(detail, result.Value);
|
||||
Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ServiceFails_ReturnsError()
|
||||
{
|
||||
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>())
|
||||
.Returns(Error.NotFound(description: "Album not found"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.YouTube;
|
||||
using Musify.Application.YouTube.Responses;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.YouTube;
|
||||
|
||||
public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||
|
||||
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
|
||||
{
|
||||
var provisioner = new YouTubeTrackProvisioner(
|
||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||
var issuer = new TrackStreamIssuer(
|
||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
|
||||
|
||||
return new ResolveYouTubeTrackStreamCommandHandler(
|
||||
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
||||
|
||||
var user = TestEntities.User(1);
|
||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||
await SeedAsync(user, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
|
||||
Assert.Equal("token", result.Value.Ticket);
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory()
|
||||
{
|
||||
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
|
||||
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
|
||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
|
||||
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
|
||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||
await SeedAsync(TestEntities.User(42));
|
||||
|
||||
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
|
||||
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
|
||||
Assert.NotNull(result.Value.TrackId);
|
||||
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync());
|
||||
Assert.Equal(42, history.UserId);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.YouTube;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.YouTube;
|
||||
|
||||
public sealed class SearchYouTubeAlbumsQueryHandlerTests
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||
.SearchAlbumsAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
||||
{
|
||||
var expected = new YouTubeAlbumSearchResult([], "next-token");
|
||||
youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
||||
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Same(expected, result.Value);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.YouTube;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.YouTube;
|
||||
|
||||
public sealed class SearchYouTubeTracksQueryHandlerTests
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), CancellationToken.None);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||
.SearchSongsAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
||||
{
|
||||
var expected = new YouTubeSearchResult([], "next-token");
|
||||
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
||||
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Same(expected, result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Musify.Domain\Musify.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Domain.Tests.ValueObjects;
|
||||
|
||||
public sealed class TrackAudioTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ProcessingStatus.Pending, false, false, true)]
|
||||
[InlineData(ProcessingStatus.Processing, false, false, true)]
|
||||
[InlineData(ProcessingStatus.Completed, true, false, false)]
|
||||
[InlineData(ProcessingStatus.Failed, false, true, false)]
|
||||
public void ComputedFlags_ReflectTranscodeStatus(
|
||||
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
|
||||
{
|
||||
var audio = new TrackAudio { TranscodeStatus = status };
|
||||
|
||||
Assert.Equal(expectedProcessed, audio.IsProcessed);
|
||||
Assert.Equal(expectedFailed, audio.IsFailed);
|
||||
Assert.Equal(expectedInProgress, audio.IsInProgress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Domain.Tests.ValueObjects;
|
||||
|
||||
public sealed class TrackPicturesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ProcessingStatus.Pending, false)]
|
||||
[InlineData(ProcessingStatus.Processing, false)]
|
||||
[InlineData(ProcessingStatus.Completed, true)]
|
||||
[InlineData(ProcessingStatus.Failed, false)]
|
||||
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
|
||||
{
|
||||
var pictures = new TrackPictures { ProcessingStatus = status };
|
||||
|
||||
Assert.Equal(expected, pictures.IsProcessed);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,31 @@ openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_p
|
||||
- API: `StreamTicket:PrivateKeyPath`.
|
||||
- Gateway: `StreamTicket:PublicKeyPath`.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
dotnet test backend/Musify.slnx
|
||||
```
|
||||
|
||||
`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene dos proyectos xUnit,
|
||||
sin Docker ni Postgres real:
|
||||
|
||||
| Proyecto | Cubre |
|
||||
|---|---|
|
||||
| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. |
|
||||
| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). |
|
||||
|
||||
`IDatabase` expone `DbSet<T>` directamente, así que los handlers arman LINQ
|
||||
real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con
|
||||
NSubstitute, no hay `IQueryable` detrás de un mock. En su lugar,
|
||||
`TestSupport/TestDatabase.cs` es un `DbContext` real sobre SQLite en memoria
|
||||
(una conexión `:memory:` nueva por test, con foreign keys reales — a diferencia
|
||||
del proveedor InMemory de EF Core, SQLite además soporta transacciones, que dos
|
||||
handlers usan). `TestSupport/TestEntities.cs` y `TestConfigurations.cs`
|
||||
construyen entidades y configuración con valores por defecto sensatos.
|
||||
`IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con
|
||||
NSubstitute, al ser interfaces normales.
|
||||
|
||||
## Config (AppSettings)
|
||||
|
||||
Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros;
|
||||
|
||||
+10
-4
@@ -1,7 +1,7 @@
|
||||
# Proyectos
|
||||
|
||||
Los seis proyectos .NET viven en `backend/` (solución `Musify.slnx`); el cliente
|
||||
web en `web-player/`.
|
||||
Los proyectos .NET viven en `backend/` (solución `Musify.slnx`, carpetas
|
||||
`Core/`, `Hosts/` y `Tests/`); el cliente web en `web-player/`.
|
||||
|
||||
## Musify.Api (`:5111`)
|
||||
|
||||
@@ -59,8 +59,14 @@ Cliente web (SvelteKit 2 + Svelte 5 + Tailwind 4). La sesión OIDC vive en el
|
||||
servidor (cookie cifrada); las llamadas al backend salen del servidor con el
|
||||
access token, y las subidas se hacen contra las URLs prefirmadas de S3.
|
||||
|
||||
## Musify.Domain.Tests + Musify.Application.Tests
|
||||
|
||||
`backend/Tests/`, carpeta `/Tests/` en el `.slnx`. xUnit, sin Docker ni Postgres
|
||||
real — `Application.Tests` corre los handlers contra SQLite en memoria (ver
|
||||
[development.md](development.md#tests)).
|
||||
|
||||
## Otras carpetas
|
||||
|
||||
- **`deploy/`** — stacks de Docker Compose (dev y prod) y los scripts de
|
||||
arranque, migraciones y provisioning. Ver [../deploy/README.md](../deploy/README.md).
|
||||
- **`deploy/`** — stacks de Docker Compose (dev y prod), autocontenidos. Ver
|
||||
[../deploy/README.md](../deploy/README.md).
|
||||
- **`docs/`** — esta documentación.
|
||||
|
||||
Reference in New Issue
Block a user