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.