Files
Musify/backend/Tests/Musify.Api.Tests/Endpoints/CoauthorEndpointsTests.cs
T
Admin 03f5936944 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.
2026-09-25 02:14:23 +02:00

143 lines
8.1 KiB
C#

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());
}
}
}