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