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:
@@ -0,0 +1,142 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class CoauthorEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private async Task<Guid> SeedTrackAsync(long ownerId, string title, bool isPublished = true)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
OwnerUserId = ownerId,
|
||||
IsPublished = isPublished,
|
||||
Audio = new TrackAudio { TranscodeStatus = ProcessingStatus.Completed, FolderName = "folder" }
|
||||
};
|
||||
database.Tracks.Add(track);
|
||||
await database.SaveChangesAsync(Ct);
|
||||
return track.Id;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(Ct);
|
||||
return JsonDocument.Parse(text).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string Id(long id) => id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task<Guid> InviteAndGetInvitationIdAsync(long ownerId, Guid trackId, long guestId)
|
||||
{
|
||||
var invite = await fixture.CreateAuthenticatedClient(ownerId)
|
||||
.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guestId]), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, invite.StatusCode);
|
||||
|
||||
var invitations = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(guestId)
|
||||
.GetAsync("/users/me/coauthor-invitations?pageNumber=1&pageSize=10", Ct));
|
||||
return invitations.GetProperty("items").EnumerateArray()
|
||||
.Single(item => item.GetProperty("trackId").GetGuid() == trackId)
|
||||
.GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coauthors_FullFlow_ExposesOnlyAcceptedCoauthorsPublicly()
|
||||
{
|
||||
var owner = await fixture.SeedUserAsync(name: "coauthor-owner");
|
||||
var guest = await fixture.SeedUserAsync(name: "coauthor-guest");
|
||||
var trackId = await SeedTrackAsync(owner, "Shared song");
|
||||
var ownerClient = fixture.CreateAuthenticatedClient(owner);
|
||||
var guestClient = fixture.CreateAuthenticatedClient(guest);
|
||||
|
||||
var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest);
|
||||
|
||||
var beforeAccept = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct));
|
||||
var ownerListBefore = await ReadJsonAsync(await ownerClient.GetAsync($"/tracks/users/{Id(owner)}", Ct));
|
||||
var duplicate = await ownerClient.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guest]), Ct);
|
||||
var accept = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct);
|
||||
var acceptAgain = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/reject", null, Ct);
|
||||
var afterAccept = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct));
|
||||
var ownerListAfter = await ReadJsonAsync(await ownerClient.GetAsync($"/tracks/users/{Id(owner)}", Ct));
|
||||
var guestProfile = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/users/{Id(guest)}", Ct));
|
||||
var guestLibrary = await ReadJsonAsync(await guestClient.GetAsync($"/tracks/users/{Id(guest)}", Ct));
|
||||
|
||||
Assert.Equal(0, beforeAccept.GetProperty("coauthors").GetArrayLength());
|
||||
var pendingInvite = ownerListBefore.GetProperty("items")[0].GetProperty("coauthorInvites")[0];
|
||||
Assert.Equal("Pending", pendingInvite.GetProperty("status").GetString());
|
||||
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, accept.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, acceptAgain.StatusCode);
|
||||
Assert.Equal("coauthor-guest", afterAccept.GetProperty("coauthors")[0].GetProperty("name").GetString());
|
||||
Assert.Equal("Accepted", ownerListAfter.GetProperty("items")[0].GetProperty("coauthorInvites")[0].GetProperty("status").GetString());
|
||||
Assert.Equal(trackId, guestProfile.GetProperty("items").EnumerateArray().Single().GetProperty("id").GetGuid());
|
||||
Assert.Equal(0, guestProfile.GetProperty("items")[0].GetProperty("coauthorInvites").GetArrayLength());
|
||||
Assert.Equal(0, guestLibrary.GetProperty("items").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coauthors_Permissions_AreEnforced()
|
||||
{
|
||||
var owner = await fixture.SeedUserAsync();
|
||||
var guest = await fixture.SeedUserAsync();
|
||||
var stranger = await fixture.SeedUserAsync();
|
||||
var trackId = await SeedTrackAsync(owner, "Guarded");
|
||||
var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest);
|
||||
|
||||
var strangerInvites = await fixture.CreateAuthenticatedClient(stranger)
|
||||
.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([stranger]), Ct);
|
||||
var self = await fixture.CreateAuthenticatedClient(owner)
|
||||
.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([owner]), Ct);
|
||||
var strangerAccepts = await fixture.CreateAuthenticatedClient(stranger)
|
||||
.PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct);
|
||||
var strangerRemoves = await fixture.CreateAuthenticatedClient(stranger)
|
||||
.DeleteAsync($"/tracks/{trackId}/coauthors/{Id(guest)}", Ct);
|
||||
var anonymous = await fixture.CreateAnonymousClient()
|
||||
.GetAsync("/users/me/coauthor-invitations?pageNumber=1&pageSize=10", Ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, strangerInvites.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, self.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, strangerAccepts.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, strangerRemoves.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coauthors_OwnerRemovesAndReinvitesRejected_Works()
|
||||
{
|
||||
var owner = await fixture.SeedUserAsync();
|
||||
var guest = await fixture.SeedUserAsync();
|
||||
var trackId = await SeedTrackAsync(owner, "Again");
|
||||
var ownerClient = fixture.CreateAuthenticatedClient(owner);
|
||||
var guestClient = fixture.CreateAuthenticatedClient(guest);
|
||||
var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest);
|
||||
|
||||
var reject = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/reject", null, Ct);
|
||||
var reinvite = await ownerClient.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guest]), Ct);
|
||||
var accept = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct);
|
||||
var withdraw = await guestClient.DeleteAsync($"/tracks/{trackId}/coauthors/{Id(guest)}", Ct);
|
||||
var track = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, reject.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, reinvite.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, accept.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, withdraw.StatusCode);
|
||||
Assert.Equal(0, track.GetProperty("coauthors").GetArrayLength());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user