From 611d9cd436f3ad74dd2c10b4f5459e36d5618184 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 9 Sep 2026 18:29:05 +0200 Subject: [PATCH] Add backend test suite under backend/Tests/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two xUnit projects, wired into Musify.slnx under a /Tests/ folder that mirrors Core/ and Hosts/: Musify.Domain.Tests Value-object logic (TrackAudio/TrackPictures' IsProcessed/IsFailed/IsInProgress). Entities are otherwise anemic EF models with no behavior to test. Musify.Application.Tests All ~45 Application handlers and services (Albums, Tracks, PlayLists, Users, Mixes, YouTube, plus TrackStreamIssuer, UploadIntentValidator, YouTubeTrackProvisioner). IDatabase exposes DbSet directly, so handlers compose real LINQ queries against it — that can't be expressed with a mock (no IQueryable provider behind one). TestSupport/TestDatabase.cs is instead a real DbContext on a private SQLite in-memory connection per test (fresh per test since xUnit instantiates the test class per test method): fast, no external dependency, and — unlike EF Core's InMemory provider — SQLite enforces real foreign keys and supports transactions, which two handlers need. External dependencies (IStorageService, IEventBus, IYouTubeMusicService, ...) are substituted with NSubstitute. TestSupport/TestEntities.cs and TestConfigurations.cs build fully-populated domain entities and configuration POCOs with sensible defaults for every required member, so no test method needs to fill in properties it doesn't care about, and no builder ever hands back null. Also, needed to make the Application layer itself testable without a real Postgres or user-secrets: - DatabaseDesignTimeFactory now falls back to the Database__ConnectionString env var, and DesignSettings.json is optional — unblocks the `migrate` one-shot container from deploy/compose.yml, which hit the same gap. - Directory.Packages.props: xunit, xunit.runner.visualstudio, NSubstitute, Microsoft.EntityFrameworkCore.Sqlite (pinned to the same 10.0.8 as the rest of EF Core), Microsoft.NET.Test.Sdk, coverlet.collector. Also pins SQLitePCLRaw.bundle_e_sqlite3 to 2.1.13 — EFCore.Sqlite 10.0.8 pulls 2.1.11 transitively, which has a known high-severity advisory (GHSA-2m69-gcr7-jv3q). - backend/.dockerignore excludes Tests/: no application Dockerfile needs it in its build context. 162 tests, all green: `dotnet test backend/Musify.slnx`. --- README.md | 6 +- backend/.dockerignore | 2 + backend/Directory.Packages.props | 11 + backend/Musify.slnx | 4 + .../AddTrackToAlbumCommandHandlerTests.cs | 111 ++++++++ .../Albums/CreateAlbumCommandHandlerTests.cs | 43 ++++ .../Albums/DeleteAlbumCommandHandlerTests.cs | 46 ++++ .../Albums/GetAlbumByIdQueryHandlerTests.cs | 36 +++ .../Albums/GetAlbumTracksQueryHandlerTests.cs | 38 +++ .../GetAlbumsByUserIdQueryHandlerTests.cs | 33 +++ .../Albums/GetAlbumsQueryHandlerTests.cs | 48 ++++ ...RecentlyListenedAlbumsQueryHandlerTests.cs | 60 +++++ ...RemoveTrackFromAlbumCommandHandlerTests.cs | 72 ++++++ .../Albums/UpdateAlbumCommandHandlerTests.cs | 57 +++++ ...GenerateMixesForUserCommandHandlerTests.cs | 76 ++++++ .../Mixes/GetMixByIdQueryHandlerTests.cs | 47 ++++ .../GetMixesByUserIdQueryHandlerTests.cs | 38 +++ .../Musify.Application.Tests.csproj | 29 +++ .../AddTrackToPlayListCommandHandlerTests.cs | 92 +++++++ ...uTubeTrackToPlayListCommandHandlerTests.cs | 105 ++++++++ .../CreatePlayListCommandHandlerTests.cs | 86 +++++++ .../DeletePlayListCommandHandlerTests.cs | 53 ++++ .../GetPlayListByIdQueryHandlerTests.cs | 35 +++ .../GetPlayListCoverQueryHandlerTests.cs | 37 +++ .../GetPlayListTracksQueryHandlerTests.cs | 38 +++ .../GetPlayListsByUserIdQueryHandlerTests.cs | 42 ++++ .../GetPlayListsQueryHandlerTests.cs | 31 +++ ...oveTrackFromPlayListCommandHandlerTests.cs | 59 +++++ ...layListPictureUploadCommandHandlerTests.cs | 74 ++++++ .../UpdatePlayListCommandHandlerTests.cs | 80 ++++++ .../Services/TrackStreamIssuerTests.cs | 50 ++++ .../Services/UploadIntentValidatorTests.cs | 145 +++++++++++ .../Services/YouTubeTrackProvisionerTests.cs | 103 ++++++++ .../Shared/StorageKeyTests.cs | 40 +++ .../Shared/TrackApplicationResponseTests.cs | 52 ++++ .../TestSupport/HandlerTestBase.cs | 33 +++ .../TestSupport/TestConfigurations.cs | 24 ++ .../TestSupport/TestDatabase.cs | 211 ++++++++++++++++ .../TestSupport/TestEntities.cs | 237 ++++++++++++++++++ .../Tracks/CreateTrackCommandHandlerTests.cs | 98 ++++++++ .../Tracks/DeleteTrackCommandHandlerTests.cs | 76 ++++++ .../GetListeningHistoryQueryHandlerTests.cs | 38 +++ .../Tracks/GetTrackByIdQueryHandlerTests.cs | 34 +++ .../Tracks/GetTrackCoverQueryHandlerTests.cs | 52 ++++ .../Tracks/GetTrackStreamQueryHandlerTests.cs | 67 +++++ .../GetTracksByUserIdQueryHandlerTests.cs | 45 ++++ .../Tracks/GetTracksQueryHandlerTests.cs | 45 ++++ ...questTrackUploadUrlsCommandHandlerTests.cs | 80 ++++++ .../Users/CreateUserCommandHandlerTests.cs | 40 +++ .../Users/GetUserByIdQueryHandlerTests.cs | 31 +++ .../Users/GetUsersQueryHandlerTests.cs | 33 +++ .../Users/SyncUserCommandHandlerTests.cs | 42 ++++ .../GetYouTubeAlbumQueryHandlerTests.cs | 61 +++++ ...veYouTubeTrackStreamCommandHandlerTests.cs | 65 +++++ .../SearchYouTubeAlbumsQueryHandlerTests.cs | 36 +++ .../SearchYouTubeTracksQueryHandlerTests.cs | 36 +++ .../Musify.Domain.Tests.csproj | 25 ++ .../ValueObjects/TrackAudioTests.cs | 22 ++ .../ValueObjects/TrackPicturesTests.cs | 19 ++ docs/development.md | 25 ++ docs/projects.md | 14 +- 61 files changed, 3363 insertions(+), 5 deletions(-) create mode 100644 backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs create mode 100644 backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs create mode 100644 backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs create mode 100644 backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj create mode 100644 backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs create mode 100644 backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs diff --git a/README.md b/README.md index 7493a26..2c8bda3 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/backend/.dockerignore b/backend/.dockerignore index 32afcf6..4916b04 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -8,3 +8,5 @@ .claude/ **/DesignSettings.json *.user +# Test projects: never shipped, no app Dockerfile needs them in its build context. +Tests/ diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 7af8198..eec9cdd 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -50,6 +50,17 @@ + + + + + + + + + + diff --git a/backend/Musify.slnx b/backend/Musify.slnx index 8bdfa9a..3cea692 100644 --- a/backend/Musify.slnx +++ b/backend/Musify.slnx @@ -9,4 +9,8 @@ + + + + diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..b76d265 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..0f78f82 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..13b01ca --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -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()); + + [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)); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs new file mode 100644 index 0000000..395cc4f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs new file mode 100644 index 0000000..05f72ae --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..8a21158 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs new file mode 100644 index 0000000..5f059ab --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs new file mode 100644 index 0000000..10421e5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..e4d1e13 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs new file mode 100644 index 0000000..1a2df91 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs new file mode 100644 index 0000000..8318ad8 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -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(); + + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger()); + + [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(), Arg.Any(), Arg.Any()) + .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()).Returns(callInfo => callInfo.Arg()); + + 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()); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs new file mode 100644 index 0000000..c9bafe7 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..0da9e56 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj b/backend/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj new file mode 100644 index 0000000..9fb6ec6 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj @@ -0,0 +1,29 @@ + + + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..1494096 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..68c570c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs @@ -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(); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); + + private AddYouTubeTrackToPlayListCommandHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + + return new AddYouTubeTrackToPlayListCommandHandler( + Database, + eventBus, + youTubeMusicService, + provisioner, + TestConfigurations.Storage(), + TestConfigurations.Track(), + NoOpLogger()); + } + + 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(), Arg.Any()); + } + + [Fact] + public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() + { + youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); + youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); + + 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(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..99a55b9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -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(); + private readonly IStorageService storageService = Substitute.For(); + + private CreatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + 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(), Arg.Any()); + } + + [Fact] + public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..f2f3954 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -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(); + + private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [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(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs new file mode 100644 index 0000000..c2a57b2 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs new file mode 100644 index 0000000..31d5896 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs new file mode 100644 index 0000000..63a3053 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..d576b4a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs new file mode 100644 index 0000000..9f3abde --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..b29b376 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs new file mode 100644 index 0000000..687e905 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -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(); + + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestPlayListPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + 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()); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..791522e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -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(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + 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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs new file mode 100644 index 0000000..af3649c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -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(); + + 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()).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(), Arg.Any()).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/"); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs new file mode 100644 index 0000000..820f1da --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -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(); + 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()) + .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(), Arg.Any(), Arg.Any()) + .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(), Arg.Any(), Arg.Any()) + .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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs new file mode 100644 index 0000000..e2c7134 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs @@ -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(); + + private YouTubeTrackProvisioner CreateProvisioner() => + new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + + [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()).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()) + .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()); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs new file mode 100644 index 0000000..8311815 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs @@ -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()); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs new file mode 100644 index 0000000..6006c81 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs new file mode 100644 index 0000000..ee8881a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Musify.Application.Tests.TestSupport; + +/// +/// Base class for handler tests: gives every test method its own empty, isolated +/// (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 . +/// +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 NoOpLogger() => NullLogger.Instance; + + /// Adds the entities and persists them, so handlers under test see committed data. + protected async Task SeedAsync(params object[] entities) + { + foreach (var entity in entities) + Database.Add(entity); + + await Database.SaveChangesAsync(CancellationToken.None); + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs new file mode 100644 index 0000000..cec69a8 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -0,0 +1,24 @@ +using Musify.Application.Configuration; + +namespace Musify.Application.Tests.TestSupport; + +/// +/// Configuration POCOs with every required member filled and the rest left at their +/// production defaults, so handler tests don't have to know which fields matter for them. +/// +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 }; +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs new file mode 100644 index 0000000..5e46523 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -0,0 +1,211 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Tests.TestSupport; + +/// +/// An backed by a private, in-memory SQLite database. +/// +/// +/// +/// exposes raw properties, so handlers +/// compose real LINQ queries (Where, Select, Include, ...) against it. +/// Substituting with a mock cannot express that — there is no +/// 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. +/// +/// +/// The model mirrors the parts of Musify.Infrastructure.Persistence.Database 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. +/// +/// +public sealed class TestDatabase : DbContext, IDatabase +{ + private readonly SqliteConnection connection; + + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) + { + this.connection = connection; + } + + /// + /// Creates a fresh, empty database with its own private connection. Call + /// (via ) to populate it, then + /// when the test is done. + /// + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Filename=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .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().OwnsOne(track => track.Pictures); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity().OwnsOne(track => track.Audio); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity().ToTable("LocalTracks"); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity().ToTable("ExternalTracks"); + + modelBuilder.Entity() + .HasIndex(track => new { track.Source, track.ExternalId }) + .IsUnique(); + + modelBuilder.Entity() + .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); + + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Track) + .WithMany(track => track.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Artist) + .WithMany(artist => artist.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.ArtistId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(artist => artist.ExternalId) + .IsUnique(); + + modelBuilder.Entity().OwnsOne(playList => playList.Pictures); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); + + modelBuilder.Entity().OwnsOne(album => album.Pictures); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + + modelBuilder.Entity().ToTable("UserAlbums"); + + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity().ToTable("ExternalAlbums"); + + modelBuilder.Entity() + .HasIndex(album => new { album.Source, album.ExternalId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); + + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + } + + public DbSet Users => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet UserAlbums => Set(); + + public DbSet ExternalAlbums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet Tracks => Set(); + + public DbSet LocalTracks => Set(); + + public DbSet ExternalTracks => Set(); + + public DbSet Artists => Set(); + + public DbSet TrackArtists => Set(); + + public DbSet UserHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public async Task 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(); + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs new file mode 100644 index 0000000..94dab2e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -0,0 +1,237 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tests.TestSupport; + +/// +/// Builds fully-populated domain entities for tests, with sensible defaults for every +/// required member. Every factory always returns a valid instance — never null — so +/// callers never need a null check on what this class hands back. +/// +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 + }; +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs new file mode 100644 index 0000000..815d0e3 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -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(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateTrackCommandHandler CreateHandler() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + return new CreateTrackCommandHandler( + Database, + eventBus, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + 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(), Arg.Any()); + + 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(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs new file mode 100644 index 0000000..91569d4 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -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(); + + private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [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(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs new file mode 100644 index 0000000..5c508b4 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs new file mode 100644 index 0000000..76beca2 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs new file mode 100644 index 0000000..cac06ac --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs new file mode 100644 index 0000000..925b828 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -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(); + + private GetTrackStreamQueryHandler CreateHandler() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any()).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()); + } + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..65330c4 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs new file mode 100644 index 0000000..65e5857 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs new file mode 100644 index 0000000..dece335 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -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(); + + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + + return new RequestTrackUploadUrlsCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + 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()); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs new file mode 100644 index 0000000..32cb742 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs new file mode 100644 index 0000000..edf9a00 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs new file mode 100644 index 0000000..97b1c1c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs new file mode 100644 index 0000000..6efc66f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs @@ -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()); + + [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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs new file mode 100644 index 0000000..4e9bc7a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs @@ -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(); + + private GetYouTubeAlbumQueryHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + + return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger()); + } + + 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()).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()) + .Returns(Error.NotFound(description: "Album not found")); + + var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), CancellationToken.None); + + Assert.True(result.IsError); + } +} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs new file mode 100644 index 0000000..4ddb78f --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs @@ -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(); + private readonly IStreamTicketService ticketService = Substitute.For(); + + private ResolveYouTubeTrackStreamCommandHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + var issuer = new TrackStreamIssuer( + Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway()); + + return new ResolveYouTubeTrackStreamCommandHandler( + Database, youTubeMusicService, provisioner, issuer, NoOpLogger()); + } + + [Fact] + public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any()).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()) + .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); + youTubeMusicService.GetSongAsync("new-video", Arg.Any()) + .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); + youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); + 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); + } +} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs new file mode 100644 index 0000000..202a786 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs @@ -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(); + + 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()).Returns(expected); + + var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), CancellationToken.None); + + Assert.False(result.IsError); + Assert.Same(expected, result.Value); + } +} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs new file mode 100644 index 0000000..abcd852 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs @@ -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(); + + 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()).Returns(expected); + + var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), CancellationToken.None); + + Assert.False(result.IsError); + Assert.Same(expected, result.Value); + } +} diff --git a/backend/Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj b/backend/Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj new file mode 100644 index 0000000..f0963a1 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj @@ -0,0 +1,25 @@ + + + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs new file mode 100644 index 0000000..7b4e0a5 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs new file mode 100644 index 0000000..a9b0ca3 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs @@ -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); + } +} diff --git a/docs/development.md b/docs/development.md index 6986d01..adb3590 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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` 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; diff --git a/docs/projects.md b/docs/projects.md index 345dd77..e19ddda 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -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.