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,35 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Coauthors.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<CoauthorInvitationResponse>>;
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
{
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken)
{
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.UserId == request.UserId
&& coauthor.Status == CoauthorStatus.Pending
&& coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderByDescending(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.Id)
.Select(coauthor => new CoauthorInvitationResponse(
coauthor.Id,
coauthor.TrackId,
coauthor.Track.Title,
coauthor.Track.Owner.Name,
coauthor.Track.OwnerUserId,
coauthor.InvitedAt))
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
}
@@ -0,0 +1,84 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList<long> UserIds)
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
public class InviteCoauthorsCommandHandler(
IDatabase database,
ILogger<InviteCoauthorsCommandHandler> logger)
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken)
{
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken);
if (found.IsError)
return found.Errors;
var userIds = request.UserIds.Distinct().ToList();
if (userIds.Contains(request.OwnerId))
return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor.");
var existingUserIds = await database.Users
.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.Select(user => user.Id)
.ToListAsync(cancellationToken);
var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault();
if (missing != null)
return AppErrors.NotFound("User", missing.Value);
var existing = await database.TrackCoauthors
.Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId))
.ToListAsync(cancellationToken);
var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected);
if (alreadyInvited != null)
return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track.");
var now = DateTime.UtcNow;
foreach (var rejected in existing)
{
rejected.Status = CoauthorStatus.Pending;
rejected.InvitedAt = now;
rejected.RespondedAt = null;
}
var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet();
foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id)))
{
await database.TrackCoauthors.AddAsync(new TrackCoauthor
{
TrackId = request.TrackId,
UserId = userId,
InvitedAt = now
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken);
if (saved.IsError)
return saved.Errors;
logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId);
return await database.TrackCoauthors
.AsNoTracking()
.Where(coauthor => coauthor.TrackId == request.TrackId)
.OrderBy(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
.ToListAsync(cancellationToken);
}
}
}
@@ -0,0 +1,47 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId)
: ICommand<ErrorOr<Success>>;
public class RemoveCoauthorCommandHandler(
IDatabase database,
ILogger<RemoveCoauthorCommandHandler> logger)
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken)
{
var ownerUserId = await database.Tracks
.AsNoTracking()
.Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => (long?)track.OwnerUserId)
.SingleOrDefaultAsync(cancellationToken);
if (ownerUserId == null)
return AppErrors.NotFound("Track", request.TrackId);
if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId)
return AppErrors.Forbidden("Track", request.TrackId);
var coauthor = await database.TrackCoauthors
.SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken);
if (coauthor == null)
return AppErrors.NotFound("Coauthor", request.CoauthorUserId);
database.TrackCoauthors.Remove(coauthor);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}",
request.UserId, request.CoauthorUserId, request.TrackId);
return Result.Success;
}
}
}
@@ -0,0 +1,44 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Coauthors
{
public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept)
: ICommand<ErrorOr<Success>>;
public class RespondToCoauthorInvitationCommandHandler(
IDatabase database,
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken)
{
var invitation = await database.TrackCoauthors
.Include(coauthor => coauthor.Track)
.SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken);
if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active)
return AppErrors.NotFound("CoauthorInvitation", request.InvitationId);
if (invitation.UserId != request.UserId)
return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId);
if (invitation.Status != CoauthorStatus.Pending)
return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered.");
invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected;
invitation.RespondedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}",
request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId);
return Result.Success;
}
}
}
@@ -0,0 +1,10 @@
namespace Musify.Application.Coauthors.Responses
{
public record CoauthorInvitationResponse(
Guid Id,
Guid TrackId,
string TrackTitle,
string? Artist,
long OwnerUserId,
DateTime InvitedAt);
}
@@ -32,6 +32,8 @@ namespace Musify.Application.Contracts
public DbSet<UserFollow> UserFollows { get; } public DbSet<UserFollow> UserFollows { get; }
public DbSet<TrackCoauthor> TrackCoauthors { get; }
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public DbSet<TEntity> Set<TEntity>() where TEntity : class; public DbSet<TEntity> Set<TEntity>() where TEntity : class;
@@ -15,12 +15,11 @@ namespace Musify.Application.Tracks
{ {
return await database.ListeningHistories return await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Include(t => t.Track.Owner)
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished) .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished)
.OrderByDescending(t => t.ListenedAt) .OrderByDescending(t => t.ListenedAt)
.ThenBy(t => t.Id) .ThenBy(t => t.Id)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .Select(t => t.Track)
.SelectResponse()
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
} }
} }
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks
int PageSize, int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null, IReadOnlyList<ProcessingStatus>? Statuses = null,
long? ViewerId = null, long? ViewerId = null,
bool? IsPublished = null) bool? IsPublished = null,
bool? IncludeCoauthored = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database) public class GetTracksByUserIdQueryHandler(IDatabase database)
@@ -25,11 +26,19 @@ namespace Musify.Application.Tracks
{ {
var tracksQuery = database.Tracks var tracksQuery = database.Tracks
.AsNoTracking() .AsNoTracking()
.Active() .Active();
.Where(track => track.OwnerUserId == request.UserId);
if (request.ViewerId != request.UserId) var isOwnerView = request.ViewerId == request.UserId;
tracksQuery = tracksQuery.WherePublished(); var includeCoauthored = request.IncludeCoauthored ?? !isOwnerView;
tracksQuery = includeCoauthored
? tracksQuery.Where(track => track.OwnerUserId == request.UserId
|| track.Coauthors.Any(coauthor => coauthor.UserId == request.UserId && coauthor.Status == CoauthorStatus.Accepted))
: tracksQuery.Where(track => track.OwnerUserId == request.UserId);
tracksQuery = isOwnerView
? tracksQuery.Where(track => track.OwnerUserId == request.UserId || track.IsPublished)
: tracksQuery.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name)) if (!string.IsNullOrWhiteSpace(request.Name))
{ {
@@ -46,11 +55,18 @@ namespace Musify.Application.Tracks
if (request.IsPublished is { } isPublished) if (request.IsPublished is { } isPublished)
tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished); tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished);
return await tracksQuery var page = await tracksQuery
.OrderByDescending(track => track.CreatedAt) .OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id) .ThenBy(track => track.Id)
.SelectUserResponse() .SelectUserResponse(includeInvites: request.ViewerId != null)
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
// Invitations are private to the track owner.
var items = page.Items
.Select(track => track.OwnerUserId == request.ViewerId ? track : track with { CoauthorInvites = [] })
.ToList();
return page with { Items = items };
} }
} }
} }
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
long OwnerUserId, long OwnerUserId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit, bool IsExplicit,
bool IsPublished) bool IsPublished,
IReadOnlyList<TrackCoauthorReference> Coauthors)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -30,6 +31,7 @@ namespace Musify.Application.Tracks.Responses
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit, track.IsExplicit,
track.IsPublished); track.IsPublished,
[]);
} }
} }
@@ -0,0 +1,8 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record TrackCoauthorReference(long Id, string Name);
public record TrackCoauthorInviteReference(long UserId, string Name, CoauthorStatus Status);
}
@@ -27,9 +27,15 @@ namespace Musify.Application.Tracks.Responses
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit, track.IsExplicit,
track.IsPublished)); track.IsPublished,
track.Coauthors
.Where(coauthor => coauthor.Status == CoauthorStatus.Accepted)
.OrderBy(coauthor => coauthor.RespondedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name))
.ToList()));
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) => public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query, bool includeInvites = false) =>
query.Select(track => new UserTrackApplicationResponse( query.Select(track => new UserTrackApplicationResponse(
track.Id, track.Id,
track.Title, track.Title,
@@ -48,6 +54,19 @@ namespace Musify.Application.Tracks.Responses
.OrderBy(albumTrack => albumTrack.AddedAt) .OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId) .ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title)) .Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title))
.ToList())); .ToList(),
track.Coauthors
.Where(coauthor => coauthor.Status == CoauthorStatus.Accepted)
.OrderBy(coauthor => coauthor.RespondedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name))
.ToList(),
includeInvites
? track.Coauthors
.OrderBy(coauthor => coauthor.InvitedAt)
.ThenBy(coauthor => coauthor.UserId)
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
.ToList()
: new List<TrackCoauthorInviteReference>()));
} }
} }
@@ -17,5 +17,7 @@ namespace Musify.Application.Tracks.Responses
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit, bool IsExplicit,
bool IsPublished, bool IsPublished,
IReadOnlyList<TrackAlbumReference> Albums); IReadOnlyList<TrackAlbumReference> Albums,
IReadOnlyList<TrackCoauthorReference> Coauthors,
IReadOnlyList<TrackCoauthorInviteReference> CoauthorInvites);
} }
@@ -45,5 +45,7 @@ namespace Musify.Domain.Entities
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>(); public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>(); public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
public ICollection<TrackCoauthor> Coauthors { get; set; } = new List<TrackCoauthor>();
} }
} }
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities
{
[Table("TrackCoauthors")]
public class TrackCoauthor
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public required Guid TrackId { get; set; }
public required long UserId { get; set; }
public CoauthorStatus Status { get; set; } = CoauthorStatus.Pending;
public DateTime InvitedAt { get; set; } = DateTime.UtcNow;
public DateTime? RespondedAt { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; } = null!;
[ForeignKey(nameof(UserId))]
public User User { get; set; } = null!;
}
}
@@ -0,0 +1,9 @@
namespace Musify.Domain.ValueObjects
{
public enum CoauthorStatus
{
Pending = 0,
Accepted = 1,
Rejected = 2
}
}
@@ -59,6 +59,8 @@ namespace Musify.Infrastructure.Persistence
public DbSet<UserFollow> UserFollows => Set<UserFollow>(); public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{ {
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackCoauthors : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TrackCoauthors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
InvitedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TrackCoauthors", x => x.Id);
table.ForeignKey(
name: "FK_TrackCoauthors_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TrackCoauthors_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TrackCoauthors_TrackId_UserId",
table: "TrackCoauthors",
columns: new[] { "TrackId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TrackCoauthors_UserId_Status",
table: "TrackCoauthors",
columns: new[] { "UserId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrackCoauthors");
}
}
}
@@ -467,6 +467,37 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("Tracks"); b.ToTable("Tracks");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("InvitedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("RespondedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId", "UserId")
.IsUnique();
b.HasIndex("UserId", "Status");
b.ToTable("TrackCoauthors");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -989,6 +1020,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("Coauthors")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{ {
b.HasOne("Musify.Domain.Entities.Track", "Track") b.HasOne("Musify.Domain.Entities.Track", "Track")
@@ -1068,6 +1118,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
{ {
b.Navigation("AlbumTracks"); b.Navigation("AlbumTracks");
b.Navigation("Coauthors");
b.Navigation("ListeningHistories"); b.Navigation("ListeningHistories");
b.Navigation("PlayListTracks"); b.Navigation("PlayListTracks");
@@ -122,6 +122,25 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique(); .IsUnique();
modelBuilder.Entity<TrackCoauthor>()
.HasOne(coauthor => coauthor.Track)
.WithMany(track => track.Coauthors)
.HasForeignKey(coauthor => coauthor.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackCoauthor>()
.HasOne(coauthor => coauthor.User)
.WithMany()
.HasForeignKey(coauthor => coauthor.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackCoauthor>()
.HasIndex(coauthor => new { coauthor.TrackId, coauthor.UserId })
.IsUnique();
modelBuilder.Entity<TrackCoauthor>()
.HasIndex(coauthor => new { coauthor.UserId, coauthor.Status });
modelBuilder.Entity<UserFollow>() modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower) .HasOne(follow => follow.Follower)
.WithMany() .WithMany()
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record InviteCoauthorsRequest(IReadOnlyList<long> UserIds);
}
@@ -0,0 +1,57 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.Extensions;
using Musify.Application.Coauthors;
namespace Musify.Api.Endpoints
{
public static class CoauthorEndpoints
{
public static IEndpointRouteBuilder MapCoauthorEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/coauthor-invitations")
.WithTags("Coauthors")
.RequireAuthorization();
group.MapPost("/{id:guid}/accept", AcceptInvitation)
.WithName("AcceptCoauthorInvitation")
.WithSummary("Accept a pending coauthor invitation addressed to you")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/{id:guid}/reject", RejectInvitation)
.WithName("RejectCoauthorInvitation")
.WithSummary("Reject a pending coauthor invitation addressed to you")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
return app;
}
private static async Task<IResult> AcceptInvitation(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new RespondToCoauthorInvitationCommand(currentUser.RequiredId, id, true), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectInvitation(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new RespondToCoauthorInvitationCommand(currentUser.RequiredId, id, false), cancellationToken);
return result.ToHttpResult();
}
}
}
@@ -5,6 +5,7 @@ using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
using Musify.Api.Models; using Musify.Api.Models;
using Musify.Application.Coauthors;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Pictures; using Musify.Application.Pictures;
using Musify.Application.Shared; using Musify.Application.Shared;
@@ -121,6 +122,27 @@ namespace Musify.Api.Endpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden); .Produces(StatusCodes.Status403Forbidden);
group.MapPost("/{id:guid}/coauthors", InviteCoauthors)
.WithName("InviteCoauthors")
.WithSummary("Invite users to coauthor one of your tracks. Returns every invitation of the track with its status. A rejected invitation is reset to pending")
.AddEndpointFilter<ValidationFilter<InviteCoauthorsRequest>>()
.RequireAuthorization()
.Produces<IReadOnlyList<TrackCoauthorInviteReference>>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{id:guid}/coauthors/{userId:long}", RemoveCoauthor)
.WithName("RemoveCoauthor")
.WithSummary("Remove a coauthor or pending invitation from a track. Allowed to the track owner and to the coauthor themself")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-urls", RequestTrackUploadUrls) group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls") .WithName("RequestTrackUploadUrls")
.WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them") .WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them")
@@ -198,14 +220,37 @@ namespace Musify.Api.Endpoints
[AsParameters] PageQuery page, [AsParameters] PageQuery page,
CancellationToken cancellationToken, CancellationToken cancellationToken,
ProcessingStatus[]? status = null, ProcessingStatus[]? status = null,
bool? published = null) bool? published = null,
bool? includeCoauthored = null)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published), new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published, includeCoauthored),
cancellationToken); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> InviteCoauthors(
IMediator mediator,
CurrentUser currentUser,
Guid id,
InviteCoauthorsRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new InviteCoauthorsCommand(currentUser.RequiredId, id, request.UserIds), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveCoauthor(
IMediator mediator,
CurrentUser currentUser,
Guid id,
long userId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new RemoveCoauthorCommand(currentUser.RequiredId, id, userId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetTrackPublished( private static async Task<IResult> SetTrackPublished(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -3,6 +3,8 @@ using Musify.Api.Authentication;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
using Musify.Api.Models; using Musify.Api.Models;
using Musify.Application.Coauthors;
using Musify.Application.Coauthors.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
@@ -24,6 +26,14 @@ namespace Musify.Api.Endpoints
.WithSummary("Get paginated users") .WithSummary("Get paginated users")
.Produces<PaginatedResponse<UserSummaryResponse>>(); .Produces<PaginatedResponse<UserSummaryResponse>>();
group.MapGet("/me/coauthor-invitations", GetCoauthorInvitations)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetCoauthorInvitations")
.WithSummary("Get your pending coauthor invitations with the track title and owner")
.RequireAuthorization()
.Produces<PaginatedResponse<CoauthorInvitationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{id:long}", GetUserById) group.MapGet("/{id:long}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
.WithSummary("Get a user by id") .WithSummary("Get a user by id")
@@ -108,6 +118,16 @@ namespace Musify.Api.Endpoints
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> GetCoauthorInvitations(
IMediator mediator,
CurrentUser currentUser,
[AsParameters] PageQuery page,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetCoauthorInvitationsQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetUserById( private static async Task<IResult> GetUserById(
IMediator mediator, IMediator mediator,
long id, long id,
+1
View File
@@ -45,6 +45,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapCoauthorEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapGenreEndpoints() .MapGenreEndpoints()
.MapLikeEndpoints() .MapLikeEndpoints()
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks
{
public sealed class InviteCoauthorsRequestValidator : AbstractValidator<InviteCoauthorsRequest>
{
public const int MaxUsersPerRequest = 20;
public InviteCoauthorsRequestValidator()
{
RuleFor(x => x.UserIds)
.NotEmpty()
.Must(ids => ids.Count <= MaxUsersPerRequest)
.WithMessage($"At most {MaxUsersPerRequest} users can be invited at once.");
RuleForEach(x => x.UserIds)
.GreaterThan(0);
}
}
}
@@ -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<UserFollow> UserFollows => Set<UserFollow>();
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{ {
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);