Add track coauthors with invitations, acceptance and coauthor-aware track responses

Owners invite existing users to coauthor a track; invitees accept or reject,
and either side can remove a coauthor. Only accepted coauthors appear in track
responses and in the coauthor's public track list. The owner also receives the
full invitation list with statuses on the user tracks response.
This commit is contained in:
2026-09-25 02:14:23 +02:00
parent a888a44b41
commit 03f5936944
29 changed files with 2267 additions and 18 deletions
@@ -0,0 +1,373 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Coauthors;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Coauthors
{
public sealed class CoauthorHandlersTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private InviteCoauthorsCommandHandler CreateInviteHandler() => new(Database, NoOpLogger<InviteCoauthorsCommandHandler>());
private RemoveCoauthorCommandHandler CreateRemoveHandler() => new(Database, NoOpLogger<RemoveCoauthorCommandHandler>());
private RespondToCoauthorInvitationCommandHandler CreateRespondHandler() => new(Database, NoOpLogger<RespondToCoauthorInvitationCommandHandler>());
private static TrackCoauthor Coauthor(Track track, User user, CoauthorStatus status) =>
new() { TrackId = track.Id, UserId = user.Id, Status = status };
[Fact]
public async Task Invite_Owner_CreatesPendingInvitations()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
await SeedAsync(owner, guest, track);
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct);
Assert.False(result.IsError);
var invite = Assert.Single(result.Value);
Assert.Equal(guest.Id, invite.UserId);
Assert.Equal("guest", invite.Name);
Assert.Equal(CoauthorStatus.Pending, invite.Status);
}
[Fact]
public async Task Invite_NotTheOwner_IsForbidden()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var track = TestEntities.Track(owner);
await SeedAsync(owner, other, track);
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(other.Id, track.Id, [other.Id]), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Invite_Self_IsRejected()
{
var owner = TestEntities.User(1, "owner");
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [owner.Id]), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(Database.TrackCoauthors);
}
[Fact]
public async Task Invite_UnknownUser_IsNotFound()
{
var owner = TestEntities.User(1, "owner");
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [99]), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Theory]
[InlineData(CoauthorStatus.Pending)]
[InlineData(CoauthorStatus.Accepted)]
public async Task Invite_AlreadyInvited_IsConflict(CoauthorStatus status)
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
await SeedAsync(owner, guest, track, Coauthor(track, guest, status));
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
Assert.Equal(status, Assert.Single(Database.TrackCoauthors).Status);
}
[Fact]
public async Task Invite_PreviouslyRejected_ResetsToPending()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
var rejected = Coauthor(track, guest, CoauthorStatus.Rejected);
rejected.RespondedAt = DateTime.UtcNow;
await SeedAsync(owner, guest, track, rejected);
var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct);
Assert.False(result.IsError);
var stored = await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct);
Assert.Equal(CoauthorStatus.Pending, stored.Status);
Assert.Null(stored.RespondedAt);
}
[Fact]
public async Task Accept_Invitee_MakesThemAnAcceptedCoauthor()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
var invitation = Coauthor(track, guest, CoauthorStatus.Pending);
await SeedAsync(owner, guest, track, invitation);
var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, Accept: true), Ct);
Assert.False(result.IsError);
var stored = await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct);
Assert.Equal(CoauthorStatus.Accepted, stored.Status);
Assert.NotNull(stored.RespondedAt);
}
[Fact]
public async Task Reject_Invitee_MarksTheInvitationRejected()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
var invitation = Coauthor(track, guest, CoauthorStatus.Pending);
await SeedAsync(owner, guest, track, invitation);
var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, Accept: false), Ct);
Assert.False(result.IsError);
Assert.Equal(CoauthorStatus.Rejected, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status);
}
[Fact]
public async Task Respond_NotTheInvitee_IsForbidden()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
var invitation = Coauthor(track, guest, CoauthorStatus.Pending);
await SeedAsync(owner, guest, track, invitation);
var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(owner.Id, invitation.Id, Accept: true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.Equal(CoauthorStatus.Pending, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status);
}
[Theory]
[InlineData(CoauthorStatus.Accepted, true)]
[InlineData(CoauthorStatus.Accepted, false)]
[InlineData(CoauthorStatus.Rejected, true)]
[InlineData(CoauthorStatus.Rejected, false)]
public async Task Respond_AlreadyAnswered_IsConflict(CoauthorStatus status, bool accept)
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner);
var invitation = Coauthor(track, guest, status);
await SeedAsync(owner, guest, track, invitation);
var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, accept), Ct);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
Assert.Equal(status, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status);
}
[Fact]
public async Task Respond_UnknownInvitation_IsNotFound()
{
var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(1, Guid.NewGuid(), Accept: true), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Remove_OwnerOrCoauthorThemself_DeletesTheRow()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var second = TestEntities.User(3, "second");
var track = TestEntities.Track(owner);
await SeedAsync(owner, guest, second, track,
Coauthor(track, guest, CoauthorStatus.Accepted),
Coauthor(track, second, CoauthorStatus.Pending));
var byOwner = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(owner.Id, track.Id, second.Id), Ct);
var byCoauthor = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(guest.Id, track.Id, guest.Id), Ct);
Assert.False(byOwner.IsError);
Assert.False(byCoauthor.IsError);
Assert.Empty(Database.TrackCoauthors);
}
[Fact]
public async Task Remove_AnotherUser_IsForbidden()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var stranger = TestEntities.User(3, "stranger");
var track = TestEntities.Track(owner);
await SeedAsync(owner, guest, stranger, track, Coauthor(track, guest, CoauthorStatus.Accepted));
var result = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(stranger.Id, track.Id, guest.Id), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.Single(Database.TrackCoauthors);
}
[Fact]
public async Task Remove_NoSuchCoauthor_IsNotFound()
{
var owner = TestEntities.User(1, "owner");
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(owner.Id, track.Id, 2), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task GetInvitations_ReturnsOnlyPendingOnesOfTheCaller()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var pendingTrack = TestEntities.Track(owner, "Pending one");
var acceptedTrack = TestEntities.Track(owner, "Accepted one");
var rejectedTrack = TestEntities.Track(owner, "Rejected one");
var otherTrack = TestEntities.Track(owner, "For someone else");
await SeedAsync(owner, guest, pendingTrack, acceptedTrack, rejectedTrack, otherTrack,
Coauthor(pendingTrack, guest, CoauthorStatus.Pending),
Coauthor(acceptedTrack, guest, CoauthorStatus.Accepted),
Coauthor(rejectedTrack, guest, CoauthorStatus.Rejected),
Coauthor(otherTrack, owner, CoauthorStatus.Pending));
var result = await new GetCoauthorInvitationsQueryHandler(Database)
.Handle(new GetCoauthorInvitationsQuery(guest.Id, 1, 10), Ct);
var invitation = Assert.Single(result.Items);
Assert.Equal("Pending one", invitation.TrackTitle);
Assert.Equal("owner", invitation.Artist);
Assert.Equal(owner.Id, invitation.OwnerUserId);
}
[Fact]
public async Task GetTrackById_ShowsOnlyAcceptedCoauthors()
{
var owner = TestEntities.User(1, "owner");
var accepted = TestEntities.User(2, "accepted");
var pending = TestEntities.User(3, "pending");
var rejected = TestEntities.User(4, "rejected");
var track = TestEntities.Track(owner);
await SeedAsync(owner, accepted, pending, rejected, track,
Coauthor(track, accepted, CoauthorStatus.Accepted),
Coauthor(track, pending, CoauthorStatus.Pending),
Coauthor(track, rejected, CoauthorStatus.Rejected));
var result = await new GetTrackByIdQueryHandler(Database).Handle(new GetTrackByIdQuery(track.Id, owner.Id), Ct);
var coauthor = Assert.Single(result.Value.Coauthors);
Assert.Equal(accepted.Id, coauthor.Id);
Assert.Equal("accepted", coauthor.Name);
}
[Fact]
public async Task GetTrackById_UnpublishedTrack_StaysHiddenFromItsCoauthor()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, guest, track, Coauthor(track, guest, CoauthorStatus.Accepted));
var result = await new GetTrackByIdQueryHandler(Database).Handle(new GetTrackByIdQuery(track.Id, guest.Id), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task GetTracksByUserId_PublicProfile_IncludesPublishedCoauthoredTracksOnly()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var own = TestEntities.Track(guest, "Own");
var shared = TestEntities.Track(owner, "Shared");
var hidden = TestEntities.Track(owner, "Hidden", isPublished: false);
var pending = TestEntities.Track(owner, "Pending");
var rejected = TestEntities.Track(owner, "Rejected");
await SeedAsync(owner, guest, own, shared, hidden, pending, rejected,
Coauthor(shared, guest, CoauthorStatus.Accepted),
Coauthor(hidden, guest, CoauthorStatus.Accepted),
Coauthor(pending, guest, CoauthorStatus.Pending),
Coauthor(rejected, guest, CoauthorStatus.Rejected));
var result = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: owner.Id), Ct);
Assert.Equal(["Own", "Shared"], result.Value.Items.Select(track => track.Title).Order().ToList());
}
[Fact]
public async Task GetTracksByUserId_OwnLibrary_ExcludesCoauthoredTracksByDefault()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var own = TestEntities.Track(guest, "Own");
var shared = TestEntities.Track(owner, "Shared");
await SeedAsync(owner, guest, own, shared, Coauthor(shared, guest, CoauthorStatus.Accepted));
var handler = new GetTracksByUserIdQueryHandler(Database);
var library = await handler.Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: guest.Id), Ct);
var withCoauthored = await handler.Handle(
new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: guest.Id, IncludeCoauthored: true), Ct);
Assert.Equal(["Own"], library.Value.Items.Select(track => track.Title).ToList());
Assert.Equal(2, withCoauthored.Value.Items.Count);
}
[Fact]
public async Task GetTracksByUserId_Invites_AreVisibleOnlyToTheOwner()
{
var owner = TestEntities.User(1, "owner");
var accepted = TestEntities.User(2, "accepted");
var pending = TestEntities.User(3, "pending");
var track = TestEntities.Track(owner);
await SeedAsync(owner, accepted, pending, track,
Coauthor(track, accepted, CoauthorStatus.Accepted),
Coauthor(track, pending, CoauthorStatus.Pending));
var handler = new GetTracksByUserIdQueryHandler(Database);
var asOwner = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10, ViewerId: owner.Id), Ct);
var asStranger = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10, ViewerId: pending.Id), Ct);
var anonymous = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), Ct);
var ownerItem = Assert.Single(asOwner.Value.Items);
Assert.Equal(2, ownerItem.CoauthorInvites.Count);
Assert.Contains(ownerItem.CoauthorInvites, invite => invite.Name == "pending" && invite.Status == CoauthorStatus.Pending);
Assert.Equal(accepted.Id, Assert.Single(ownerItem.Coauthors).Id);
Assert.Empty(Assert.Single(asStranger.Value.Items).CoauthorInvites);
Assert.Empty(Assert.Single(anonymous.Value.Items).CoauthorInvites);
Assert.Equal(accepted.Id, Assert.Single(Assert.Single(anonymous.Value.Items).Coauthors).Id);
}
[Fact]
public async Task GetTracksByUserId_CoauthoredTrack_KeepsSharedListensCount()
{
var owner = TestEntities.User(1, "owner");
var guest = TestEntities.User(2, "guest");
var listener = TestEntities.User(3, "listener");
var track = TestEntities.Track(owner);
await SeedAsync(owner, guest, listener, track, Coauthor(track, guest, CoauthorStatus.Accepted),
TestEntities.ListeningHistory(listener.Id, track.Id),
TestEntities.ListeningHistory(listener.Id, track.Id));
var result = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10), Ct);
Assert.Equal(2, Assert.Single(result.Value.Items).ListensCount);
}
}
}
@@ -64,6 +64,8 @@ namespace Musify.Application.Tests.TestSupport
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);