Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete

This commit is contained in:
2026-09-24 05:14:05 +02:00
parent ab388e6ad6
commit 0e3576922f
90 changed files with 2083 additions and 322 deletions
@@ -39,7 +39,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -49,7 +49,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -65,7 +65,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
@@ -75,7 +75,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -1,16 +1,21 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
{
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteAlbumCommandHandler>());
[Fact]
public async Task Handle_Owner_RemovesAlbum()
public async Task Handle_Owner_MarksAlbumAsRemoving()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
@@ -19,7 +24,10 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteAlbumEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
@@ -31,7 +39,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -40,7 +48,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
}
@@ -34,4 +34,19 @@ public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.AlbumPictures.Pending("original.png"));
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
}
@@ -46,7 +46,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -55,7 +55,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -77,7 +77,7 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -88,6 +88,6 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
}
@@ -53,7 +53,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -62,7 +62,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -89,4 +89,18 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
[Fact]
public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 });
var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken);
Assert.Equal("Test.Duplicate", result.FirstError.Code);
}
}
@@ -39,7 +39,7 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -48,6 +48,6 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
}
@@ -33,7 +33,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -42,7 +42,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -56,4 +56,26 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, playList, first, second, third,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 });
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList();
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([0, 1], remaining.Select(link => link.Position));
}
}
@@ -48,7 +48,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -59,7 +59,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -93,7 +93,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
@@ -105,7 +105,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
Assert.False(result.IsError);
Assert.Equal("Original Name", result.Value.Name);
Assert.Equal("Original description", result.Value.Description);
Assert.Null(result.Value.Description);
}
[Fact]
@@ -126,7 +126,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName);
Assert.Null(result.Value.SmallImageKeyName);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
@@ -1,3 +1,4 @@
using ErrorOr;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
@@ -79,6 +80,10 @@ public sealed class TestDatabase : DbContext, IDatabase
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.IsUnique();
modelBuilder.Entity<PlayListHasTrack>()
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
.IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
@@ -164,6 +169,19 @@ public sealed class TestDatabase : DbContext, IDatabase
return new SqliteDatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
{
try
{
await SaveChangesAsync(cancellationToken);
return Result.Success;
}
catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 })
{
return onUniqueViolation;
}
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
@@ -39,7 +39,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
@@ -48,7 +48,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
@@ -1,4 +1,3 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
@@ -22,18 +21,18 @@ public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestB
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Newer listen", result.Value.Title);
Assert.NotNull(result);
Assert.Equal("Newer listen", result.Title);
}
[Fact]
public async Task Handle_NoHistory_ReturnsNotFound()
public async Task Handle_NoHistory_ReturnsNull()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
Assert.Null(result);
}
}
@@ -27,19 +27,4 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
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.Track(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), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
}
}
@@ -102,7 +102,7 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
}
[Fact]
public async Task Handle_NoListenToday_StreakIsZero()
public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
@@ -112,6 +112,20 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(1, result.StreakDays);
}
[Fact]
public async Task Handle_NoListenTodayOrYesterday_StreakIsZero()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.StreakDays);
}
}