Merge backend library endpoints

This commit is contained in:
2026-09-25 01:38:19 +02:00
88 changed files with 3833 additions and 143 deletions
@@ -19,7 +19,9 @@ namespace Musify.Application.Albums
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
@@ -38,6 +40,21 @@ namespace Musify.Application.Albums
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
.ToListAsync(cancellationToken);
var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
@@ -53,11 +70,19 @@ namespace Musify.Application.Albums
NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures
};
await database.Albums.AddAsync(album, cancellationToken);
for (var index = 0; index < trackIds.Count; index++)
{
await database.AlbumHasTracks.AddAsync(
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
cancellationToken);
}
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
@@ -65,9 +90,15 @@ namespace Musify.Application.Albums
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
return AlbumApplicationResponse.FromEntity(
album,
visibleTrackIds.Count,
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
}
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId)
: IQuery<ErrorOr<AlbumTrackIdsResponse>>;
public class GetAlbumTrackIdsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTrackIdsQuery, ErrorOr<AlbumTrackIdsResponse>>
{
public async ValueTask<ErrorOr<AlbumTrackIdsResponse>> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var trackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId
&& albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
return new AlbumTrackIdsResponse(trackIds);
}
}
}
@@ -28,7 +28,7 @@ namespace Musify.Application.Albums
return await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
@@ -20,6 +20,7 @@ namespace Musify.Application.Albums
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join(
listens,
albumTrack => albumTrack.TrackId,
@@ -1,4 +1,5 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses
{
@@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
@@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? []);
coverTrackIds ?? [],
album.Type);
}
}
}
@@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
.ToList(),
album.Type));
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
@@ -13,7 +14,8 @@ namespace Musify.Application.Albums
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear)
int? ReleaseYear,
AlbumType? Type = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
@@ -34,6 +36,7 @@ namespace Musify.Application.Albums
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear;
album.Type = request.Type ?? album.Type;
await database.SaveChangesAsync(cancellationToken);
@@ -10,10 +10,10 @@ namespace Musify.Application.Configuration
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -4,11 +4,11 @@ namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string? PictureSourceKey,
string? PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
@@ -15,7 +15,7 @@ namespace Musify.Application.Genres
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
@@ -20,7 +20,7 @@ namespace Musify.Application.Likes
{
return await database.TrackLikes
.AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
.OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id)
.Select(like => like.Track)
@@ -21,7 +21,7 @@ namespace Musify.Application.Likes
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
@@ -25,7 +25,7 @@ namespace Musify.Application.Mixes
var libraryTrackIds = await database.Tracks
.AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
@@ -25,7 +25,13 @@ namespace Musify.Application.Mixes
mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList());
var coverItems = orderedItemsByMix.Values
var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
entry => entry.Key,
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
var coverItems = visibleItemsByMix.Values
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
@@ -35,7 +41,7 @@ namespace Musify.Application.Mixes
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = orderedItemsByMix[mix.Id];
var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
@@ -9,6 +9,22 @@ namespace Musify.Application.Mixes
internal static class MixItemMapper
{
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
IDatabase database,
IEnumerable<MixItem> items,
CancellationToken cancellationToken)
{
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
var visible = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
return [.. visible];
}
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +35,7 @@ namespace Musify.Application.Mixes
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
@@ -35,10 +35,7 @@ namespace Musify.Application.Pictures
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
@@ -71,5 +68,34 @@ namespace Musify.Application.Pictures
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new
{
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
Album = t.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new CoverSource(
albumTrack.Album.OwnerUserId,
albumTrack.Album.Pictures!.OriginalName,
albumTrack.Album.Pictures.SmallName,
albumTrack.Album.Pictures.MediumName,
albumTrack.Album.Pictures.LargeName))
.FirstOrDefault()
})
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return (null, trackConfiguration.Routes);
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
? (track.Album, albumConfiguration.Routes)
: (track.Own, trackConfiguration.Routes);
}
}
}
@@ -34,7 +34,7 @@ namespace Musify.Application.PlayLists
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
@@ -27,7 +27,7 @@ namespace Musify.Application.PlayLists
var existingIds = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
return await database.PlayListHasTracks
.AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
@@ -25,14 +25,15 @@ namespace Musify.Application.PlayLists.Responses
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
}
@@ -15,7 +15,8 @@ namespace Musify.Application.Services
PictureRoutes Routes,
string FileType,
string ContentType,
long? ExpectedSizeBytes);
long? ExpectedSizeBytes,
int? SharedKeyGroup = null);
public sealed record IssuedUpload(
Guid IntentId,
@@ -43,11 +44,15 @@ namespace Musify.Application.Services
if (!userExists)
return AppErrors.NotFound("User", userId);
var sharedKeys = new Dictionary<int, string>();
var planned = requests
.Select(request =>
{
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
if (request.SharedKeyGroup is { } group)
key = sharedKeys.TryAdd(group, key) ? key : sharedKeys[group];
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
return (Request: request, ObjectName: objectName, Key: key, Size: size);
})
@@ -16,7 +16,7 @@ namespace Musify.Application.Tracks
public record CreateTrackCommand(
long UserId,
string Title,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
@@ -45,20 +45,27 @@ namespace Musify.Application.Tracks
if (request.PictureIntentId == request.AudioIntentId)
return Error.Validation(description: "Picture and audio must use different upload intents.");
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
UploadIntent? pictureIntent = null;
if (request.PictureIntentId is { } pictureIntentId)
{
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
pictureIntent = pictureValidation.Value;
}
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
var pictureIntent = pictureValidation.Value;
var audioIntent = audioValidation.Value;
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var finalPictureKey = pictureIntent == null
? null
: trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -71,11 +78,11 @@ namespace Musify.Application.Tracks
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
OriginalName = pictureIntent?.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending
},
Audio = new TrackAudio
{
@@ -94,10 +101,10 @@ namespace Musify.Application.Tracks
await eventBus.PublishAsync(
new CreateTrackResourcesEvent(
trackEntity.Id,
pictureIntent.Id,
pictureIntent?.Id,
audioIntent.Id,
pictureIntent.Bucket,
pictureIntent.Key,
audioIntent.Bucket,
pictureIntent?.Key,
finalPictureKey,
audioIntent.Key,
finalAudioKey,
@@ -0,0 +1,60 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record DeleteTracksCommand(long UserId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<DeleteTracksResponse>>;
public class DeleteTracksCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTracksCommandHandler> logger)
: ICommandHandler<DeleteTracksCommand, ErrorOr<DeleteTracksResponse>>
{
public async ValueTask<ErrorOr<DeleteTracksResponse>> Handle(DeleteTracksCommand request, CancellationToken cancellationToken)
{
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.ToListAsync(cancellationToken);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var deleted = new List<Guid>();
var failed = new List<Guid>();
foreach (var trackId in requestedIds)
{
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
if (track == null || track.Audio.IsInProgress)
{
failed.Add(trackId);
continue;
}
track.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
deleted.Add(trackId);
}
if (deleted.Count > 0)
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Marked {Count} tracks of user {UserId} as removing", deleted.Count, request.UserId);
}
return new DeleteTracksResponse(deleted, failed);
}
}
}
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
.AsNoTracking()
.Include(t => t.Track.Owner)
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished)
.OrderByDescending(t => t.ListenedAt)
.ThenBy(t => t.Id)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
var tracksById = await database.Tracks
.AsNoTracking()
.Active()
.WherePublished()
.Where(t => recentTrackIds.Contains(t.Id))
.SelectResponse()
.ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
public record GetTrackByIdQuery(Guid TrackId)
public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null)
: IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database)
@@ -18,6 +18,7 @@ namespace Musify.Application.Tracks
var track = await database.Tracks
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.Where(t => t.Id == request.TrackId)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
@@ -0,0 +1,40 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTrackStatusCountsQuery(long UserId)
: IQuery<TrackStatusCountsResponse>;
public class GetTrackStatusCountsQueryHandler(IDatabase database)
: IQueryHandler<GetTrackStatusCountsQuery, TrackStatusCountsResponse>
{
public async ValueTask<TrackStatusCountsResponse> Handle(GetTrackStatusCountsQuery request, CancellationToken cancellationToken)
{
var counts = await database.Tracks
.AsNoTracking()
.Active()
.Where(track => track.OwnerUserId == request.UserId)
.GroupBy(track => new { track.Audio.TranscodeStatus, track.IsPublished })
.Select(group => new { group.Key.TranscodeStatus, group.Key.IsPublished, Count = group.Count() })
.ToListAsync(cancellationToken);
int CountOf(ProcessingStatus status) => counts.Where(c => c.TranscodeStatus == status).Sum(c => c.Count);
var published = counts.Where(c => c.IsPublished).Sum(c => c.Count);
var total = counts.Sum(c => c.Count);
return new TrackStatusCountsResponse(
total,
CountOf(ProcessingStatus.Pending),
CountOf(ProcessingStatus.Processing),
CountOf(ProcessingStatus.Completed),
CountOf(ProcessingStatus.Failed),
published,
total - published);
}
}
}
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
{
var tracksQuery = database.Tracks
.AsNoTracking()
.Active();
.Active()
.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name))
{
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
@@ -11,29 +12,44 @@ namespace Musify.Application.Tracks
long UserId,
string? Name,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null,
long? ViewerId = null,
bool? IsPublished = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.Tracks
.AsNoTracking()
.Active()
.Where(track => track.OwnerUserId == request.UserId);
if (request.ViewerId != request.UserId)
tracksQuery = tracksQuery.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name))
{
var normalizedName = TextNormalizer.Normalize(request.Name);
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
}
if (request.Statuses is { Count: > 0 } statuses)
{
var statusList = statuses.Distinct().ToList();
tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus));
}
if (request.IsPublished is { } isPublished)
tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished);
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
.SelectResponse()
.SelectUserResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -7,14 +7,17 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null);
public record RequestTrackUploadUrlsCommand(
long UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
string? PictureFileType,
string? PictureContentType,
string? AudioFileType = null,
string? AudioContentType = null,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null)
long? ExpectedAudioSizeBytes = null,
IReadOnlyList<AudioUploadSpec>? Audios = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler(
@@ -24,35 +27,75 @@ namespace Musify.Application.Tracks
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{
private const int PictureKeyGroup = 1;
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
{
var audios = ResolveAudios(request);
if (audios.Count == 0)
return Error.Validation(description: "At least one audio file is required.");
var hasPicture = request.PictureFileType != null && request.PictureContentType != null;
var routes = trackConfiguration.Routes;
var issued = await issuer.IssueAsync(
request.UserId,
[
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
],
cancellationToken);
var uploads = new List<UploadRequest>();
if (hasPicture)
{
for (var index = 0; index < audios.Count; index++)
{
uploads.Add(new UploadRequest(
UploadIntentPurpose.TrackPicture, routes, request.PictureFileType!, request.PictureContentType!,
request.ExpectedPictureSizeBytes, PictureKeyGroup));
}
}
uploads.AddRange(audios.Select(audio =>
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)));
var issued = await issuer.IssueAsync(request.UserId, uploads, cancellationToken);
if (issued.IsError)
return issued.Errors;
var picture = issued.Value[0];
var audio = issued.Value[1];
var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : [];
var audioUploads = issued.Value.Skip(pictures.Count).ToList();
var items = audioUploads
.Select((audio, index) => new TrackAudioUpload(
audio.IntentId,
hasPicture ? pictures[index].IntentId : null,
audio.Key,
audio.ObjectName,
audio.ContentType,
audio.UploadUrl))
.ToList();
var firstPicture = pictures.Count > 0 ? pictures[0] : null;
var firstAudio = audioUploads[0];
return new TrackUploadUrlsResponse(
picture.IntentId,
audio.IntentId,
firstPicture?.IntentId,
firstAudio.IntentId,
storageConfiguration.Bucket,
picture.Key,
picture.ObjectName,
picture.ContentType,
picture.UploadUrl,
audio.Key,
audio.ObjectName,
audio.ContentType,
audio.UploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
firstPicture?.Key,
firstPicture?.ObjectName,
firstPicture?.ContentType,
firstPicture?.UploadUrl,
firstAudio.Key,
firstAudio.ObjectName,
firstAudio.ContentType,
firstAudio.UploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
items);
}
private static IReadOnlyList<AudioUploadSpec> ResolveAudios(RequestTrackUploadUrlsCommand request)
{
if (request.Audios is { Count: > 0 } audios)
return audios;
return request.AudioFileType != null && request.AudioContentType != null
? [new AudioUploadSpec(request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)]
: [];
}
}
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
public record DeleteTracksResponse(
IReadOnlyList<Guid> DeletedTrackIds,
IReadOnlyList<Guid> FailedTrackIds);
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
public record SetTracksPublishedResponse(
IReadOnlyList<Guid> UpdatedTrackIds,
IReadOnlyList<Guid> SkippedTrackIds);
}
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
DateTime UpdatedAt,
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
bool IsExplicit,
bool IsPublished)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
track.IsExplicit,
track.IsPublished);
}
}
@@ -8,6 +8,12 @@ namespace Musify.Application.Tracks.Responses
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<Track> WherePublished(this IQueryable<Track> query) =>
query.Where(track => track.IsPublished);
public static IQueryable<Track> VisibleTo(this IQueryable<Track> query, long? viewerId) =>
query.Where(track => track.IsPublished || track.OwnerUserId == viewerId);
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
query.Select(track => new TrackApplicationResponse(
track.Id,
@@ -20,6 +26,28 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit));
track.IsExplicit,
track.IsPublished));
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) =>
query.Select(track => new UserTrackApplicationResponse(
track.Id,
track.Title,
track.Owner.Name,
track.Audio.TranscodeStatus,
track.DurationSeconds,
track.ListeningHistories.Count(listen => listen.IsCounted),
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit,
track.IsPublished,
track.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title))
.ToList()));
}
}
@@ -0,0 +1,11 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackStatusCountsResponse(
int Total,
int Pending,
int Processing,
int Completed,
int Failed,
int Published,
int Unpublished);
}
@@ -1,16 +1,25 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackAudioUpload(
Guid IntentId,
Guid? PictureIntentId,
string Key,
string Name,
string ContentType,
string UploadUrl);
public record TrackUploadUrlsResponse(
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureKey,
string PictureName,
string PictureContentType,
string PictureUploadUrl,
string? PictureKey,
string? PictureName,
string? PictureContentType,
string? PictureUploadUrl,
string AudioKey,
string AudioName,
string AudioContentType,
string AudioUploadUrl,
int ExpiresInSeconds);
int ExpiresInSeconds,
IReadOnlyList<TrackAudioUpload> Audios);
}
@@ -0,0 +1,21 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record TrackAlbumReference(Guid Id, string Title);
public record UserTrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit,
bool IsPublished,
IReadOnlyList<TrackAlbumReference> Albums);
}
@@ -0,0 +1,38 @@
using ErrorOr;
using Mediator;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Tracks
{
public record SetTrackPublishedCommand(long UserId, Guid TrackId, bool IsPublished)
: ICommand<ErrorOr<Success>>;
public class SetTrackPublishedCommandHandler(
IDatabase database,
ILogger<SetTrackPublishedCommandHandler> logger)
: ICommandHandler<SetTrackPublishedCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(SetTrackPublishedCommand request, CancellationToken cancellationToken)
{
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var track = found.Value;
if (request.IsPublished && !track.Audio.IsProcessed)
return Error.Validation("Track.NotProcessed", "Only tracks whose audio finished processing can be published.");
if (track.IsPublished == request.IsPublished)
return Result.Success;
track.IsPublished = request.IsPublished;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("{Action} track {TrackId}", request.IsPublished ? "Published" : "Unpublished", track.Id);
return Result.Success;
}
}
}
@@ -0,0 +1,58 @@
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.ValueObjects;
namespace Musify.Application.Tracks
{
public record SetTracksPublishedCommand(long UserId, IReadOnlyList<Guid> TrackIds, bool IsPublished)
: ICommand<ErrorOr<SetTracksPublishedResponse>>;
public class SetTracksPublishedCommandHandler(
IDatabase database,
ILogger<SetTracksPublishedCommandHandler> logger)
: ICommandHandler<SetTracksPublishedCommand, ErrorOr<SetTracksPublishedResponse>>
{
public async ValueTask<ErrorOr<SetTracksPublishedResponse>> Handle(SetTracksPublishedCommand request, CancellationToken cancellationToken)
{
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.ToListAsync(cancellationToken);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var updated = new List<Guid>();
var skipped = new List<Guid>();
foreach (var trackId in requestedIds)
{
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
if (track == null || (request.IsPublished && !track.Audio.IsProcessed))
{
skipped.Add(trackId);
continue;
}
track.IsPublished = request.IsPublished;
updated.Add(trackId);
}
if (updated.Count > 0)
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("{Action} {Count} tracks of user {UserId}",
request.IsPublished ? "Published" : "Unpublished", updated.Count, request.UserId);
}
return new SetTracksPublishedResponse(updated, skipped);
}
}
}
@@ -30,6 +30,7 @@ namespace Musify.Application.Tracks
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.VisibleTo(request.UserId)
.Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken);
@@ -22,6 +22,8 @@ namespace Musify.Domain.Entities
public int? ReleaseYear { get; set; }
public AlbumType Type { get; set; }
public EntityPictures? Pictures { get; set; }
public required long OwnerUserId { get; set; }
@@ -28,6 +28,8 @@ namespace Musify.Domain.Entities
public bool IsExplicit { get; set; }
public bool IsPublished { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace Musify.Domain.ValueObjects
{
public enum AlbumType
{
Album = 0,
EP = 1,
[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Part of the public API contract.")]
Single = 2
}
}
@@ -16,13 +16,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
if (arguments.FinalPictureKey != null)
{
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
}
await publishEndpoint.Publish(
new UpdateTrackAudioEvent(
@@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments
public record PublishTrackProcessingEventsArguments(
Guid TrackId,
string Bucket,
string FinalPictureKey,
string? FinalPictureKey,
string FinalAudioKey,
string AudioDestinationFolderKey,
ImageSizes Sizes);
@@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
var builder = RoutingSlips.Create(correlationId);
var hasPicture = message.PictureIntentId != null
&& message.PictureSourceKey != null
&& message.PictureDestinationKey != null;
if (hasPicture)
{
builder.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!));
}
builder
.AddStep(
ActivityNames.CopyAudioToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
@@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
new ConsumeUploadIntentsArguments(
hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId]))
.AddStep(
ActivityNames.PublishTrackProcessingEvents,
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
new PublishTrackProcessingEventsArguments(
message.TrackId,
message.Bucket,
message.PictureDestinationKey,
hasPicture ? message.PictureDestinationKey : null,
message.AudioDestinationKey,
message.AudioProcessedFolderKey,
message.Sizes))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey);
if (hasPicture)
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey!);
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
return builder;
@@ -47,7 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
}
if (track.Pictures.IsProcessed)
if (track.Pictures.IsProcessed && !string.IsNullOrEmpty(track.Pictures.OriginalName))
{
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
@@ -48,6 +48,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null;
context.Saga.AudioKey = context.Message.AudioDestinationKey;
})
.TransitionTo(Processing));
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPublicationAndAlbumType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPublished",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE \"Tracks\" SET \"IsPublished\" = TRUE;");
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPublished",
table: "Tracks");
migrationBuilder.DropColumn(
name: "Type",
table: "Albums");
}
}
}
@@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
@@ -1,8 +1,12 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Albums
{
public record CreateAlbumRequest(
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId);
Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null);
}
@@ -1,7 +1,10 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Albums
{
public record UpdateAlbumRequest(
string Title,
string? Description,
int? ReleaseYear);
int? ReleaseYear,
AlbumType? Type = null);
}
@@ -4,7 +4,7 @@ namespace Musify.Api.DataTransferObjects.Tracks
{
public record CreateTrackRequest(
string Title,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record DeleteTracksRequest(IReadOnlyList<Guid> TrackIds);
}
@@ -1,10 +1,16 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record AudioUploadRequest(
string FileType,
string ContentType,
long? ExpectedSizeBytes = null);
public record RequestTrackUploadUrlsRequest(
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
string? PictureFileType = null,
string? PictureContentType = null,
string? AudioFileType = null,
string? AudioContentType = null,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null);
long? ExpectedAudioSizeBytes = null,
IReadOnlyList<AudioUploadRequest>? Audios = null);
}
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record SetTrackPublishedRequest(bool IsPublished);
}
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record SetTracksPublishedRequest(IReadOnlyList<Guid> TrackIds, bool IsPublished);
}
@@ -57,6 +57,15 @@ namespace Musify.Api.Endpoints
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id:guid}/tracks/ids", GetAlbumTrackIds)
.WithName("GetAlbumTrackIds")
.WithSummary("Get the ids of every track in one of your albums, ordered by track number")
.RequireAuthorization()
.Produces<AlbumTrackIdsResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id:guid}/cover", GetAlbumCover)
.WithName("GetAlbumCover")
.WithSummary("Get an album cover image")
@@ -75,12 +84,13 @@ namespace Musify.Api.Endpoints
group.MapPost("/", CreateAlbum)
.WithName("CreateAlbum")
.WithSummary("Create a new album")
.WithSummary("Create a new album, optionally with some of your tracks already in it")
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
.RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{id:guid}", UpdateAlbum)
@@ -195,6 +205,16 @@ namespace Musify.Api.Endpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumTrackIds(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetAlbumTrackIdsQuery(currentUser.RequiredId, id), cancellationToken);
return result.ToHttpResult();
}
private static Task<IResult> GetAlbumCover(
IMediator mediator,
IStorageService storageService,
@@ -226,7 +246,8 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
new CreateAlbumCommand(
currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId, request.Type, request.TrackIds),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
@@ -240,7 +261,7 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear),
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.Type),
cancellationToken);
return result.ToHttpResult();
@@ -1,4 +1,5 @@
using Mediator;
using Microsoft.AspNetCore.Mvc;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions;
@@ -9,6 +10,7 @@ using Musify.Application.Pictures;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Endpoints
{
@@ -57,12 +59,20 @@ namespace Musify.Api.Endpoints
group.MapGet("/users/{userId:long}", GetTracksByUserId)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetTracksByUserId")
.WithSummary("Get paginated tracks for a user")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
.WithSummary("Get paginated tracks for a user, optionally filtered by name, audio processing status and published state. Each track lists the albums it belongs to. Only the owner sees unpublished tracks")
.Produces<PaginatedResponse<UserTrackApplicationResponse>>();
group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts)
.WithName("GetTrackStatusCounts")
.WithSummary("Get how many of your tracks are in each audio processing status")
.RequireAuthorization()
.Produces<TrackStatusCountsResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/", CreateTrack)
.WithName("CreateTrack")
.WithSummary("Create a new track")
.WithSummary("Create a new track, unpublished, from upload intents. The picture is optional")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
@@ -81,9 +91,39 @@ namespace Musify.Api.Endpoints
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/", DeleteTracks)
.WithName("DeleteTracks")
.WithSummary("Delete several of your tracks at once. Tracks that are missing or still processing are returned as failed")
.AddEndpointFilter<ValidationFilter<DeleteTracksRequest>>()
.RequireAuthorization()
.Produces<DeleteTracksResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
group.MapPut("/{id:guid}/published", SetTrackPublished)
.WithName("SetTrackPublished")
.WithSummary("Publish or unpublish one of your tracks. Only tracks with completed audio processing can be published")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/published", SetTracksPublished)
.WithName("SetTracksPublished")
.WithSummary("Publish or unpublish several of your tracks at once. Missing tracks and tracks that cannot be published yet are returned as skipped")
.AddEndpointFilter<ValidationFilter<SetTracksPublishedRequest>>()
.RequireAuthorization()
.Produces<SetTracksPublishedResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls")
.WithSummary("Request pre-signed URLs to upload track picture and audio")
.WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization()
.Produces<TrackUploadUrlsResponse>()
@@ -108,10 +148,11 @@ namespace Musify.Api.Endpoints
private static async Task<IResult> GetTrackById(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
var result = await mediator.Send(new GetTrackByIdQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
@@ -151,12 +192,63 @@ namespace Musify.Api.Endpoints
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
CurrentUser currentUser,
long userId,
string? name,
[AsParameters] PageQuery page,
CancellationToken cancellationToken,
ProcessingStatus[]? status = null,
bool? published = null)
{
var result = await mediator.Send(
new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetTrackPublished(
IMediator mediator,
CurrentUser currentUser,
Guid id,
SetTrackPublishedRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken);
var result = await mediator.Send(new SetTrackPublishedCommand(currentUser.RequiredId, id, request.IsPublished), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetTracksPublished(
IMediator mediator,
CurrentUser currentUser,
SetTracksPublishedRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new SetTracksPublishedCommand(currentUser.RequiredId, request.TrackIds, request.IsPublished),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTrackStatusCounts(
IMediator mediator,
CurrentUser currentUser,
long userId,
CancellationToken cancellationToken)
{
if (currentUser.RequiredId != userId)
return Results.Problem("You can only read the status counts of your own tracks.", statusCode: StatusCodes.Status403Forbidden);
var result = await mediator.Send(new GetTrackStatusCountsQuery(userId), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> DeleteTracks(
IMediator mediator,
CurrentUser currentUser,
[FromBody] DeleteTracksRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeleteTracksCommand(currentUser.RequiredId, request.TrackIds), cancellationToken);
return result.ToHttpResult();
}
@@ -197,7 +289,8 @@ namespace Musify.Api.Endpoints
request.AudioFileType,
request.AudioContentType,
request.ExpectedPictureSizeBytes,
request.ExpectedAudioSizeBytes),
request.ExpectedAudioSizeBytes,
request.Audios?.Select(audio => new AudioUploadSpec(audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)).ToList()),
cancellationToken);
return result.ToHttpResult();
@@ -5,6 +5,8 @@ namespace Musify.Api.Validators.Albums
{
public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest>
{
public const int MaxTracksOnCreate = 500;
public CreateAlbumRequestValidator()
{
RuleFor(x => x.Title)
@@ -20,6 +22,18 @@ namespace Musify.Api.Validators.Albums
RuleFor(x => x.PictureIntentId)
.NotEmpty();
RuleFor(x => x.Type)
.IsInEnum();
RuleFor(x => x.TrackIds)
.Must(ids => ids!.Count <= MaxTracksOnCreate)
.WithMessage($"At most {MaxTracksOnCreate} tracks can be added at once.")
.When(x => x.TrackIds != null);
RuleForEach(x => x.TrackIds)
.NotEmpty()
.When(x => x.TrackIds != null);
}
}
}
@@ -17,6 +17,10 @@ namespace Musify.Api.Validators.Albums
RuleFor(x => x.ReleaseYear)
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
.When(x => x.ReleaseYear.HasValue);
RuleFor(x => x.Type)
.IsInEnum()
.When(x => x.Type.HasValue);
}
}
}
@@ -13,7 +13,8 @@ namespace Musify.Api.Validators.Tracks
.MaximumLength(Limits.TrackTitle);
RuleFor(x => x.PictureIntentId)
.NotEmpty();
.NotEqual(Guid.Empty)
.When(x => x.PictureIntentId.HasValue);
RuleFor(x => x.AudioIntentId)
.NotEmpty();
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks
{
public sealed class DeleteTracksRequestValidator : AbstractValidator<DeleteTracksRequest>
{
public const int MaxTracksPerRequest = 100;
public DeleteTracksRequestValidator()
{
RuleFor(x => x.TrackIds)
.NotEmpty()
.Must(ids => ids.Count <= MaxTracksPerRequest)
.WithMessage($"At most {MaxTracksPerRequest} tracks can be deleted at once.");
RuleForEach(x => x.TrackIds)
.NotEmpty();
}
}
}
@@ -6,25 +6,53 @@ namespace Musify.Api.Validators.Tracks
{
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
{
public const int MaxAudiosPerRequest = 20;
public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration)
{
RuleFor(x => x.PictureFileType)
.MustBeFileType(Uploads.PictureFileTypes);
When(x => x.PictureFileType != null || x.PictureContentType != null, () =>
{
RuleFor(x => x.PictureFileType!)
.MustBeFileType(Uploads.PictureFileTypes);
RuleFor(x => x.PictureContentType)
.MustBeContentType(Uploads.PictureContentTypes);
RuleFor(x => x.PictureContentType!)
.MustBeContentType(Uploads.PictureContentTypes);
RuleFor(x => x.AudioFileType)
.MustBeFileType(Uploads.AudioFileTypes);
RuleFor(x => x.ExpectedPictureSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
RuleFor(x => x.AudioContentType)
.MustBeContentType(Uploads.AudioContentTypes);
When(x => x.Audios is { Count: > 0 }, () =>
{
RuleFor(x => x.AudioFileType)
.Null()
.WithMessage("Use either the audio fields or the audios list, not both.");
RuleFor(x => x.ExpectedPictureSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
RuleFor(x => x.AudioContentType)
.Null()
.WithMessage("Use either the audio fields or the audios list, not both.");
RuleFor(x => x.ExpectedAudioSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
RuleFor(x => x.Audios!)
.Must(audios => audios.Count <= MaxAudiosPerRequest)
.WithMessage($"At most {MaxAudiosPerRequest} audio files can be requested at once.");
RuleForEach(x => x.Audios!).ChildRules(audio =>
{
audio.RuleFor(a => a.FileType).MustBeFileType(Uploads.AudioFileTypes);
audio.RuleFor(a => a.ContentType).MustBeContentType(Uploads.AudioContentTypes);
audio.RuleFor(a => a.ExpectedSizeBytes).MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
}).Otherwise(() =>
{
RuleFor(x => x.AudioFileType!)
.MustBeFileType(Uploads.AudioFileTypes);
RuleFor(x => x.AudioContentType!)
.MustBeContentType(Uploads.AudioContentTypes);
RuleFor(x => x.ExpectedAudioSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
}
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks
{
public sealed class SetTracksPublishedRequestValidator : AbstractValidator<SetTracksPublishedRequest>
{
public const int MaxTracksPerRequest = 100;
public SetTracksPublishedRequestValidator()
{
RuleFor(x => x.TrackIds)
.NotEmpty()
.Must(ids => ids.Count <= MaxTracksPerRequest)
.WithMessage($"At most {MaxTracksPerRequest} tracks can be updated at once.");
RuleForEach(x => x.TrackIds)
.NotEmpty();
}
}
}
+2 -2
View File
@@ -118,8 +118,8 @@
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
"MaxActiveUploadBytesPerUser": 209715200,
"MaxActiveUploadIntentsPerUser": 50,
"MaxActiveUploadBytesPerUser": 1073741824,
"MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600,
+2 -2
View File
@@ -114,8 +114,8 @@
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
"MaxActiveUploadBytesPerUser": 209715200,
"MaxActiveUploadIntentsPerUser": 50,
"MaxActiveUploadBytesPerUser": 1073741824,
"MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600,
@@ -1,5 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Tests.TestSupport;
@@ -14,6 +16,11 @@ namespace Musify.Api.Tests.Endpoints
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
@@ -54,7 +61,7 @@ namespace Musify.Api.Tests.Endpoints
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId);
@@ -89,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -104,7 +111,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -119,7 +126,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -134,7 +141,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await ownerClient.PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken);
@@ -149,7 +156,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
@@ -34,6 +34,7 @@ namespace Musify.Api.Tests.Endpoints
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
IsPublished = true,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
@@ -0,0 +1,222 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
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 TrackLibraryEndpointsTests(ApiTestFixture fixture)
{
private async Task<Guid> SeedTrackAsync(
long ownerId,
string title,
ProcessingStatus status = ProcessingStatus.Completed,
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 = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
};
database.Tracks.Add(track);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return track.Id;
}
private async Task<Guid> SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId };
database.Albums.Add(album);
var number = 1;
foreach (var trackId in trackIds)
database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return album.Id;
}
private static int Number(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? int.Parse(element.GetString()!, CultureInfo.InvariantCulture)
: element.GetInt32();
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
return JsonDocument.Parse(text).RootElement.Clone();
}
[Fact]
public async Task GetUserTracks_StatusFilterBindsRepeatedQueryValues()
{
var userId = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Done");
await SeedTrackAsync(userId, "Pending", ProcessingStatus.Pending);
await SeedTrackAsync(userId, "Failed", ProcessingStatus.Failed);
var response = await fixture.CreateAnonymousClient().GetAsync(
$"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}?status=Pending&status=Failed", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
var titles = body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("title").GetString()).Order().ToList();
Assert.Equal(["Failed", "Pending"], titles);
Assert.Equal(2, Number(body.GetProperty("totalItemCount")));
}
[Fact]
public async Task GetUserTracks_ReturnsAlbumsOfEachTrack()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "In album");
var albumId = await SeedAlbumWithTracksAsync(userId, trackId);
var response = await fixture.CreateAnonymousClient().GetAsync(
$"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}", TestContext.Current.CancellationToken);
var body = await ReadJsonAsync(response);
var albums = body.GetProperty("items")[0].GetProperty("albums");
Assert.Equal(1, albums.GetArrayLength());
Assert.Equal(albumId, albums[0].GetProperty("id").GetGuid());
Assert.Equal("Album", albums[0].GetProperty("title").GetString());
}
[Fact]
public async Task GetStatusCounts_Owner_ReturnsCountsPerStatus()
{
var userId = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "A");
await SeedTrackAsync(userId, "B", ProcessingStatus.Processing);
await SeedTrackAsync(userId, "C", ProcessingStatus.Failed);
var response = await fixture.CreateAuthenticatedClient(userId).GetAsync(
$"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal(3, Number(body.GetProperty("total")));
Assert.Equal(1, Number(body.GetProperty("completed")));
Assert.Equal(1, Number(body.GetProperty("processing")));
Assert.Equal(1, Number(body.GetProperty("failed")));
Assert.Equal(0, Number(body.GetProperty("pending")));
}
[Fact]
public async Task GetStatusCounts_AnotherUser_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync(
$"/tracks/users/{owner.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task GetStatusCounts_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks/users/1/status-counts", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetAlbumTrackIds_Owner_ReturnsIdsInOrder()
{
var userId = await fixture.SeedUserAsync();
var first = await SeedTrackAsync(userId, "First");
var second = await SeedTrackAsync(userId, "Second");
var albumId = await SeedAlbumWithTracksAsync(userId, second, first);
var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
var ids = body.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList();
Assert.Equal([second, first], ids);
}
[Fact]
public async Task GetAlbumTrackIds_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var albumId = await SeedAlbumWithTracksAsync(owner);
var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task DeleteTracks_Anonymous_ReturnsUnauthorized()
{
var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([Guid.NewGuid()])) };
var response = await fixture.CreateAnonymousClient().SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task DeleteTracks_EmptyList_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([])) };
var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DeleteTracks_OwnTracks_MarksThemRemovingAndReportsFailures()
{
var userId = await fixture.SeedUserAsync();
var done = await SeedTrackAsync(userId, "Done");
var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending);
var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([done, busy])) };
var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal([done], body.GetProperty("deletedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
Assert.Equal([busy], body.GetProperty("failedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var stored = await database.Tracks.AsNoTracking().SingleAsync(t => t.Id == done, TestContext.Current.CancellationToken);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
}
[Fact]
public async Task DeleteTracks_TrackOfAnotherUser_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(owner, "Theirs");
var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([trackId])) };
var response = await fixture.CreateAuthenticatedClient(stranger).SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
@@ -0,0 +1,298 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class TrackPublicationEndpointsTests(ApiTestFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<Guid> SeedTrackAsync(
long ownerId,
string title,
ProcessingStatus status = ProcessingStatus.Completed,
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 = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
};
database.Tracks.Add(track);
await database.SaveChangesAsync(Ct);
return track.Id;
}
private async Task<Guid> SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId };
database.Albums.Add(album);
var number = 1;
foreach (var trackId in trackIds)
database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ });
await database.SaveChangesAsync(Ct);
return album.Id;
}
private void MockStorage()
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
}
private static int Number(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? int.Parse(element.GetString()!, CultureInfo.InvariantCulture)
: element.GetInt32();
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync(Ct);
return JsonDocument.Parse(text).RootElement.Clone();
}
private static string UserPath(long userId) => $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}";
[Fact]
public async Task PutPublished_Owner_PublishesTheTrackForEveryone()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var anonymous = fixture.CreateAnonymousClient();
var before = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
var publish = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var after = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
Assert.Equal(HttpStatusCode.NotFound, before.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, publish.StatusCode);
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
Assert.True((await ReadJsonAsync(after)).GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublished_AudioNotProcessed_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Processing, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PutPublished_NotTheOwnerOrAnonymous_IsRejected()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(owner, "Mine", isPublished: false);
var asStranger = await fixture.CreateAuthenticatedClient(stranger)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var anonymous = await fixture.CreateAnonymousClient()
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.Forbidden, asStranger.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task PutPublishedBatch_UpdatesProcessedTracksAndReportsTheSkippedOnes()
{
var userId = await fixture.SeedUserAsync();
var ready = await SeedTrackAsync(userId, "Ready", isPublished: false);
var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([ready, busy], true), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal([ready], body.GetProperty("updatedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
Assert.Equal([busy], body.GetProperty("skippedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
}
[Fact]
public async Task PutPublishedBatch_EmptyList_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([], true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetUserTracks_UnpublishedTracksOnlyReachTheOwner()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
var path = UserPath(userId);
var anonymous = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync(path, Ct));
var asStranger = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(stranger).GetAsync(path, Ct));
var asOwner = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync(path, Ct));
var onlyHidden = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync($"{path}?published=false", Ct));
Assert.Equal(1, Number(anonymous.GetProperty("totalItemCount")));
Assert.Equal(1, Number(asStranger.GetProperty("totalItemCount")));
Assert.Equal(2, Number(asOwner.GetProperty("totalItemCount")));
Assert.Equal("Hidden", onlyHidden.GetProperty("items")[0].GetProperty("title").GetString());
Assert.False(onlyHidden.GetProperty("items")[0].GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task StatusCounts_SplitPublishedFromUnpublished()
{
var userId = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"{UserPath(userId)}/status-counts", Ct);
var body = await ReadJsonAsync(response);
Assert.Equal(1, Number(body.GetProperty("published")));
Assert.Equal(2, Number(body.GetProperty("unpublished")));
}
[Fact]
public async Task Search_AndStream_IgnoreUnpublishedTracks()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var title = $"Secret{Guid.NewGuid():N}";
var trackId = await SeedTrackAsync(owner, title, isPublished: false);
var search = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks?name={title}", Ct));
var stream = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/tracks/{trackId}/stream", Ct);
Assert.Equal(0, Number(search.GetProperty("totalItemCount")));
Assert.Equal(HttpStatusCode.NotFound, stream.StatusCode);
}
[Fact]
public async Task GetAlbum_HidesUnpublishedTracksFromTheListAndTheCount()
{
var userId = await fixture.SeedUserAsync();
var live = await SeedTrackAsync(userId, "Live");
var hidden = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var albumId = await SeedAlbumWithTracksAsync(userId, hidden, live);
var tracks = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}/tracks", Ct));
var album = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}", Ct));
Assert.Equal(1, Number(tracks.GetProperty("totalItemCount")));
Assert.Equal(1, Number(album.GetProperty("trackCount")));
Assert.Equal("Album", album.GetProperty("type").GetString());
}
[Fact]
public async Task PostUploadUrls_ManyAudiosAndSharedPicture_ReturnsAnIntentPerFile()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var request = new RequestTrackUploadUrlsRequest(
"webp", "image/webp", Audios: [new("mp3", "audio/mpeg", 1000), new("flac", "audio/flac", 2000)]);
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync("/tracks/upload-urls", request, Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
var audios = body.GetProperty("audios").EnumerateArray().ToList();
Assert.Equal(2, audios.Count);
Assert.All(audios, audio => Assert.NotEqual(JsonValueKind.Null, audio.GetProperty("pictureIntentId").ValueKind));
Assert.Equal(2, audios.Select(audio => audio.GetProperty("intentId").GetGuid()).Distinct().Count());
Assert.Equal(2, audios.Select(audio => audio.GetProperty("pictureIntentId").GetGuid()).Distinct().Count());
}
[Fact]
public async Task PostUploadUrls_AudiosOnly_HasNoPictureIntent()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync(
"/tracks/upload-urls", new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal(JsonValueKind.Null, body.GetProperty("pictureIntentId").ValueKind);
Assert.Equal(JsonValueKind.Null, body.GetProperty("audios")[0].GetProperty("pictureIntentId").ValueKind);
}
[Fact]
public async Task PostAlbum_WithTypeAndTracks_LinksThemAndPutChangesTheType()
{
var userId = await fixture.SeedUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var first = await SeedTrackAsync(userId, "First");
var second = await SeedTrackAsync(userId, "Second");
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var intentId = upload.GetProperty("intentId").GetGuid();
var created = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Typed", null, 2024, intentId, AlbumType.EP, [second, first]), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var album = await ReadJsonAsync(created);
Assert.Equal("EP", album.GetProperty("type").GetString());
Assert.Equal(2, Number(album.GetProperty("trackCount")));
var albumId = album.GetProperty("id").GetGuid();
var ids = await ReadJsonAsync(await client.GetAsync($"/albums/{albumId}/tracks/ids", Ct));
Assert.Equal([second, first], ids.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
var updated = await client.PutAsJsonAsync($"/albums/{albumId}", new UpdateAlbumRequest("Typed", null, 2024, AlbumType.Single), Ct);
Assert.Equal("Single", (await ReadJsonAsync(updated)).GetProperty("type").GetString());
}
[Fact]
public async Task PostAlbum_WithTrackOfAnotherUser_ReturnsForbidden()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var theirs = await SeedTrackAsync(stranger, "Theirs");
var client = fixture.CreateAuthenticatedClient(userId);
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var response = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Stolen", null, null, upload.GetProperty("intentId").GetGuid(), TrackIds: [theirs]), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
@@ -31,7 +31,8 @@ namespace Musify.Api.Tests.Endpoints
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner
Owner = owner,
IsPublished = true
};
database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory
@@ -0,0 +1,122 @@
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Validators.Albums;
using Musify.Api.Validators.Tracks;
using Musify.Application.Configuration;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Api.Tests.Validators
{
public sealed class LibraryRequestValidatorTests
{
private readonly UploadIntentConfiguration _configuration = new();
[Fact]
public void CreateTrack_WithoutPicture_Passes()
{
var request = new CreateTrackRequest("Song", null, Guid.NewGuid(), [Genre.Pop]);
Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void CreateTrack_EmptyPictureIntent_Fails()
{
var request = new CreateTrackRequest("Song", Guid.Empty, Guid.NewGuid(), [Genre.Pop]);
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void UploadUrls_ManyAudiosWithSharedPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(
"png", "image/png", Audios: [new("mp3", "audio/mpeg", 10), new("flac", "audio/flac")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_AudiosWithoutPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_NoAudioAtAll_Fails()
{
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest("png", "image/png")).IsValid);
}
[Fact]
public void UploadUrls_LegacyAudioTogetherWithAudiosList_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_HalfAPicture_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", null, Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_InvalidAudioInTheList_Fails()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg"), new("png", "image/png")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_TooManyAudios_Fails()
{
var audios = Enumerable.Range(0, RequestTrackUploadUrlsRequestValidator.MaxAudiosPerRequest + 1)
.Select(_ => new AudioUploadRequest("mp3", "audio/mpeg"))
.ToList();
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest(Audios: audios)).IsValid);
}
[Fact]
public void SetTracksPublished_RequiresBetweenOneAndTheMaximumTracks()
{
var validator = new SetTracksPublishedRequestValidator();
var tooMany = Enumerable.Range(0, SetTracksPublishedRequestValidator.MaxTracksPerRequest + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new SetTracksPublishedRequest([Guid.NewGuid()], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest(tooMany, true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([Guid.Empty], true)).IsValid);
}
[Fact]
public void CreateAlbum_UnknownTypeOrTooManyTracks_Fails()
{
var validator = new CreateAlbumRequestValidator();
var tooMany = Enumerable.Range(0, CreateAlbumRequestValidator.MaxTracksOnCreate + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), AlbumType.EP, [Guid.NewGuid()])).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), (AlbumType)42)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: tooMany)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: [Guid.Empty])).IsValid);
}
[Fact]
public void UpdateAlbum_TypeIsOptionalButMustBeKnown()
{
var validator = new UpdateAlbumRequestValidator();
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null)).IsValid);
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null, AlbumType.Single)).IsValid);
Assert.False(validator.Validate(new UpdateAlbumRequest("Album", null, null, (AlbumType)42)).IsValid);
}
}
}
@@ -0,0 +1,142 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class CreateAlbumWithTracksAndTypeTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateAlbumCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
return new CreateAlbumCommandHandler(
_eventBus,
Database,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Album());
}
private static UploadIntent PictureIntent(long userId) =>
TestEntities.UploadIntent(userId, UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
[Theory]
[InlineData(AlbumType.Album)]
[InlineData(AlbumType.EP)]
[InlineData(AlbumType.Single)]
public async Task Handle_StoresAndReturnsTheAlbumType(AlbumType type)
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Typed", null, null, intent.Id, type), Ct);
Assert.False(result.IsError);
Assert.Equal(type, result.Value.Type);
Assert.Equal(type, (await Database.Albums.AsNoTracking().SingleAsync(Ct)).Type);
}
[Fact]
public async Task Handle_DefaultsToAlbumType()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Default", null, null, intent.Id), Ct);
Assert.Equal(AlbumType.Album, result.Value.Type);
}
[Fact]
public async Task Handle_WithTrackIds_CreatesAlbumAndLinksTracksInOrderInOneSave()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", isPublished: false);
var third = TestEntities.Track(owner, "Third");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, first, second, third, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "With tracks", null, null, intent.Id, AlbumType.EP, [third.Id, first.Id, second.Id, third.Id]), Ct);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TrackCount);
Assert.Equal([third.Id, first.Id], result.Value.CoverTrackIds);
var links = await Database.AlbumHasTracks.AsNoTracking()
.Where(link => link.AlbumId == result.Value.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(Ct);
Assert.Equal([third.Id, first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([1, 2, 3], links.Select(link => link.TrackNumber));
await _eventBus.Received(1).PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndCreatesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var theirs = TestEntities.Track(stranger, "Theirs");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, stranger, theirs, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Stolen", null, null, intent.Id, TrackIds: [theirs.Id]), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
Assert.Empty(await Database.AlbumHasTracks.ToListAsync(Ct));
await _eventBus.DidNotReceive().PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MissingTrack_ReturnsNotFoundAndCreatesNothing()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Ghost", null, null, intent.Id, TrackIds: [Guid.NewGuid()]), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
}
[Fact]
public async Task Update_ChangesTypeOnlyWhenProvided()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.Type = AlbumType.EP;
await SeedAsync(owner, album);
var handler = new UpdateAlbumCommandHandler(Database, NoOpLogger<UpdateAlbumCommandHandler>());
var untouched = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024), Ct);
var changed = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024, AlbumType.Single), Ct);
Assert.Equal(AlbumType.EP, untouched.Value.Type);
Assert.Equal(AlbumType.Single, changed.Value.Type);
}
}
}
@@ -0,0 +1,53 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class GetAlbumTrackIdsQueryHandlerTests : HandlerTestBase
{
private GetAlbumTrackIdsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Owner_ReturnsAllActiveTrackIdsInOrderBeyondAPage()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var tracks = Enumerable.Range(1, 120).Select(i => TestEntities.Track(owner, $"T{i}")).ToList();
var removing = TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing);
await SeedAsync([owner, album, removing, .. tracks]);
var links = tracks.Select((t, i) => new AlbumHasTrack { AlbumId = album.Id, TrackId = t.Id, TrackNumber = i + 1 }).ToList();
links.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = removing.Id, TrackNumber = 999 });
await SeedAsync([.. links]);
var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(tracks.Select(t => t.Id), result.Value.TrackIds);
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_MissingAlbum_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -57,7 +57,8 @@ namespace Musify.Application.Tests.TestSupport
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null,
bool isExplicit = false)
bool isExplicit = false,
bool isPublished = true)
{
var track = new Track
{
@@ -66,6 +67,7 @@ namespace Musify.Application.Tests.TestSupport
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
IsPublished = isPublished,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
@@ -0,0 +1,81 @@
using ErrorOr;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private DeleteTracksCommandHandler CreateHandler() => new(Database, _eventBus, NoOpLogger<DeleteTracksCommandHandler>());
[Fact]
public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "One");
var second = TestEntities.Track(owner, "Two");
await SeedAsync(owner, first, second);
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [first.Id, second.Id]), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.DeletedTrackIds.Count);
Assert.Empty(result.Value.FailedTrackIds);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus));
await _eventBus.Received(2).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndDeletesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var mine = TestEntities.Track(owner, "Mine");
var theirs = TestEntities.Track(stranger, "Theirs");
await SeedAsync(owner, stranger, mine, theirs);
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [mine.Id, theirs.Id]), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus));
await _eventBus.DidNotReceive().PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ProcessingAndMissingTracks_AreReturnedAsFailed()
{
var owner = TestEntities.User();
var done = TestEntities.Track(owner, "Done");
var busy = TestEntities.Track(owner, "Busy", audio: TestEntities.PendingAudio());
var missing = Guid.NewGuid();
await SeedAsync(owner, done, busy);
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [done.Id, busy.Id, missing]), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal([done.Id], result.Value.DeletedTrackIds);
Assert.Equal([busy.Id, missing], result.Value.FailedTrackIds);
Assert.Equal(LifeCycleStatus.Active, Database.Tracks.Single(t => t.Id == busy.Id).LifeCycleStatus);
}
[Fact]
public async Task Handle_DuplicateIds_AreDeletedOnce()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken);
Assert.Single(result.Value.DeletedTrackIds);
await _eventBus.Received(1).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
}
}
@@ -0,0 +1,45 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class GetTrackStatusCountsQueryHandlerTests : HandlerTestBase
{
[Fact]
public async Task Handle_CountsOnlyActiveTracksOfTheUser()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(
owner, other,
TestEntities.Track(owner, "Done 1"),
TestEntities.Track(owner, "Done 2"),
TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio()),
TestEntities.Track(owner, "Processing", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Processing }),
TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed }),
TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing),
TestEntities.Track(other, "Theirs"));
var counts = await new GetTrackStatusCountsQueryHandler(Database)
.Handle(new GetTrackStatusCountsQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(5, counts.Total);
Assert.Equal(1, counts.Pending);
Assert.Equal(1, counts.Processing);
Assert.Equal(2, counts.Completed);
Assert.Equal(1, counts.Failed);
}
[Fact]
public async Task Handle_NoTracks_ReturnsZeros()
{
var counts = await new GetTrackStatusCountsQueryHandler(Database)
.Handle(new GetTrackStatusCountsQuery(99), TestContext.Current.CancellationToken);
Assert.Equal(0, counts.Total);
Assert.Equal(0, counts.Completed);
}
}
}
@@ -0,0 +1,79 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class GetTracksByUserIdStatusAndAlbumsTests : HandlerTestBase
{
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_StatusFilter_ReturnsOnlyMatchingStatuses()
{
var owner = TestEntities.User();
var done = TestEntities.Track(owner, "Done");
var pending = TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio());
var failed = TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed });
await SeedAsync(owner, done, pending, failed);
var result = await CreateHandler().Handle(
new GetTracksByUserIdQuery(owner.Id, null, 1, 10, [ProcessingStatus.Pending, ProcessingStatus.Failed]),
TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(["Failed", "Pending"], result.Value.Items.Select(t => t.Title).Order().ToList());
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact]
public async Task Handle_NoStatusFilter_ReturnsEveryStatus()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B", audio: TestEntities.PendingAudio()));
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact]
public async Task Handle_ListsActiveAlbumsOfEachTrack()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, "Song");
var loose = TestEntities.Track(owner, "Loose");
var first = TestEntities.Album(owner.Id, "First");
var second = TestEntities.Album(owner.Id, "Second");
var removed = TestEntities.Album(owner.Id, "Gone");
removed.LifeCycleStatus = LifeCycleStatus.Removing;
await SeedAsync(
owner, track, loose, first, second, removed,
new AlbumHasTrack { AlbumId = first.Id, TrackId = track.Id },
new AlbumHasTrack { AlbumId = second.Id, TrackId = track.Id },
new AlbumHasTrack { AlbumId = removed.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken);
var song = result.Value.Items.Single(t => t.Title == "Song");
Assert.Equal(["First", "Second"], song.Albums.Select(a => a.Title).Order().ToList());
Assert.Contains(song.Albums, a => a.Id == first.Id);
Assert.Empty(result.Value.Items.Single(t => t.Title == "Loose").Albums);
}
[Fact]
public async Task Handle_Paginates()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B"), TestEntities.Track(owner, "C"));
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 2, 2), TestContext.Current.CancellationToken);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.True(result.Value.HasPreviousPage);
}
}
}
@@ -0,0 +1,119 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class RequestTrackUploadUrlsBatchTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private RequestTrackUploadUrlsCommandHandler CreateHandler()
{
_storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(call => $"https://storage.musify.test/{call.ArgAt<string>(1)}");
var config = TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler(
new UploadIntentIssuer(
Database,
_storageService,
new UploadIntentValidator(Database, _storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
TestConfigurations.Track(),
config);
}
private static readonly AudioUploadSpec[] _threeAudios =
[
new("mp3", "audio/mpeg", 3_000_000),
new("flac", "audio/flac", 30_000_000),
new("wav", "audio/wav", 40_000_000)
];
[Fact]
public async Task Handle_ManyAudiosAndSharedPicture_IssuesOneAudioIntentPerFileAndOnePictureIntentPerTrackOnASingleObject()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Equal(3, result.Value.Audios.Count);
Assert.All(result.Value.Audios, audio => Assert.NotNull(audio.PictureIntentId));
var intents = await Database.UploadIntents.AsNoTracking().ToListAsync(Ct);
var pictures = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackPicture).ToList();
var audios = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackAudio).ToList();
Assert.Equal(3, pictures.Count);
Assert.Equal(3, audios.Count);
Assert.Single(pictures.Select(intent => intent.Key).Distinct());
Assert.Equal(3, pictures.Select(intent => intent.ObjectName).Distinct().Count());
Assert.Equal(3, audios.Select(intent => intent.Key).Distinct().Count());
Assert.Equal(
result.Value.Audios.Select(audio => audio.PictureIntentId!.Value).Order(),
pictures.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios.Select(audio => audio.IntentId).Order(), audios.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios[0].IntentId, result.Value.AudioIntentId);
Assert.Equal(result.Value.Audios[0].PictureIntentId, result.Value.PictureIntentId);
Assert.Equal(pictures[0].Key, result.Value.PictureKey);
}
[Fact]
public async Task Handle_ManyAudiosWithoutPicture_IssuesNoPictureIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, null, null, Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Null(result.Value.PictureIntentId);
Assert.Null(result.Value.PictureUploadUrl);
Assert.All(result.Value.Audios, audio => Assert.Null(audio.PictureIntentId));
Assert.All(await Database.UploadIntents.ToListAsync(Ct), intent => Assert.Equal(UploadIntentPurpose.TrackAudio, intent.Purpose));
}
[Fact]
public async Task Handle_LegacyAudioFields_StillReturnASingleItem()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"), Ct);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Audios);
Assert.Equal(result.Value.AudioIntentId, item.IntentId);
Assert.Equal(result.Value.PictureIntentId, item.PictureIntentId);
}
[Fact]
public async Task Handle_NoAudio_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp"), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(Ct));
}
}
}
@@ -0,0 +1,146 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class SetTrackPublishedCommandHandlerTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private SetTrackPublishedCommandHandler CreateSingleHandler() => new(Database, NoOpLogger<SetTrackPublishedCommandHandler>());
private SetTracksPublishedCommandHandler CreateBatchHandler() => new(Database, NoOpLogger<SetTracksPublishedCommandHandler>());
private async Task<bool> IsPublishedAsync(Guid trackId) =>
(await Database.Tracks.AsNoTracking().SingleAsync(t => t.Id == trackId, Ct)).IsPublished;
[Fact]
public async Task Single_ProcessedTrack_CanBePublishedAndUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, track);
var publish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
var published = await IsPublishedAsync(track.Id);
var unpublish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(publish.IsError);
Assert.True(published);
Assert.False(unpublish.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Theory]
[InlineData(ProcessingStatus.Pending)]
[InlineData(ProcessingStatus.Processing)]
[InlineData(ProcessingStatus.Failed)]
public async Task Single_AudioNotCompleted_CannotBePublished(ProcessingStatus status)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: new Domain.ValueObjects.TrackAudio { TranscodeStatus = status }, isPublished: false);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_UnprocessedTrackThatIsPublished_CanStillBeUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio(), isPublished: true);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(result.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_NotTheOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, stranger, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(stranger.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_MissingOrRemovingTrack_ReturnsNotFound()
{
var owner = TestEntities.User();
var removing = TestEntities.Track(owner, lifeCycleStatus: LifeCycleStatus.Removing, isPublished: false);
await SeedAsync(owner, removing);
var missing = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, Guid.NewGuid(), true), Ct);
var gone = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, removing.Id, true), Ct);
Assert.Equal(ErrorType.NotFound, missing.FirstError.Type);
Assert.Equal(ErrorType.NotFound, gone.FirstError.Type);
}
[Fact]
public async Task Batch_PublishesProcessedTracksAndSkipsTheRest()
{
var owner = TestEntities.User();
var ready = TestEntities.Track(owner, "Ready", isPublished: false);
var processing = TestEntities.Track(owner, "Processing", audio: TestEntities.PendingAudio(), isPublished: false);
var missing = Guid.NewGuid();
await SeedAsync(owner, ready, processing);
var result = await CreateBatchHandler().Handle(
new SetTracksPublishedCommand(owner.Id, [ready.Id, processing.Id, missing, ready.Id], true), Ct);
Assert.False(result.IsError);
Assert.Equal([ready.Id], result.Value.UpdatedTrackIds);
Assert.Equal([processing.Id, missing], result.Value.SkippedTrackIds);
Assert.True(await IsPublishedAsync(ready.Id));
Assert.False(await IsPublishedAsync(processing.Id));
}
[Fact]
public async Task Batch_Unpublish_AppliesToEveryOwnedTrack()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", audio: TestEntities.PendingAudio());
await SeedAsync(owner, first, second);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [first.Id, second.Id], false), Ct);
Assert.Equal(2, result.Value.UpdatedTrackIds.Count);
Assert.Empty(result.Value.SkippedTrackIds);
Assert.False(await IsPublishedAsync(first.Id));
Assert.False(await IsPublishedAsync(second.Id));
}
[Fact]
public async Task Batch_TrackOfAnotherUser_ReturnsForbiddenAndChangesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var mine = TestEntities.Track(owner, "Mine", isPublished: false);
var theirs = TestEntities.Track(stranger, "Theirs", isPublished: false);
await SeedAsync(owner, stranger, mine, theirs);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [mine.Id, theirs.Id], true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(mine.Id));
Assert.False(await IsPublishedAsync(theirs.Id));
}
}
}
@@ -0,0 +1,152 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class TrackCreationWithoutPictureTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("audio/mpeg", 1024));
return new CreateTrackCommandHandler(
Database,
_eventBus,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Track());
}
[Fact]
public async Task CreateTrack_WithoutPicture_UsesPresetsAndSkipsPictureProcessing()
{
var user = TestEntities.User();
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, audio);
var routes = TestConfigurations.Track().Routes;
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "No cover", null, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Null(stored.Pictures.OriginalName);
Assert.Equal(routes.PresetSmallPicture, stored.Pictures.SmallName);
Assert.Equal(routes.PresetMediumPicture, stored.Pictures.MediumName);
Assert.Equal(routes.PresetLargePicture, stored.Pictures.LargeName);
Assert.Equal(ProcessingStatus.Completed, stored.Pictures.ProcessingStatus);
Assert.False(stored.IsPublished);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message =>
message.PictureIntentId == null
&& message.PictureSourceKey == null
&& message.PictureDestinationKey == null
&& message.AudioIntentId == audio.Id),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreateTrack_WithPicture_StaysUnpublishedAndKeepsPictureFlow()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "Cover", picture.Id, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal(ProcessingStatus.Pending, stored.Pictures.ProcessingStatus);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message => message.PictureIntentId == picture.Id && message.PictureSourceKey != null),
Arg.Any<CancellationToken>());
}
private GetCoverQueryHandler CreateCoverHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Fact]
public async Task Cover_TrackWithoutPictureInAnAlbum_UsesTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
var album = TestEntities.Album(owner.Id, pictures: new EntityPictures
{
OriginalName = "album-original.webp",
SmallName = "album-small.webp",
MediumName = "album-medium.webp",
LargeName = "album-large.webp"
});
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("album-small.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithoutPictureAndAlbum_FallsBackToThePreset()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
await SeedAsync(owner, track);
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("PresetSmallPicture.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithOwnPicture_IgnoresTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.EndsWith("small.webp", result.Value.Key);
Assert.DoesNotContain("album", result.Value.Key, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,259 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Genres;
using Musify.Application.Likes;
using Musify.Application.Mixes;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class UnpublishedTrackVisibilityTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<(User Owner, User Other, Track Live, Track Hidden)> SeedTracksAsync()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var live = TestEntities.Track(owner, "Live");
var hidden = TestEntities.Track(owner, "Hidden", isPublished: false, tags: [Genre.Jazz]);
await SeedAsync(owner, other, live, hidden);
return (owner, other, live, hidden);
}
[Fact]
public async Task Search_HidesUnpublishedTracksEvenFromTheOwner()
{
var (_, _, live, _) = await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20), Ct);
Assert.Equal([live.Id], result.Items.Select(t => t.Id));
}
[Fact]
public async Task Search_ByGenre_HidesUnpublishedTracks()
{
await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20, Genre.Jazz), Ct);
Assert.Empty(result.Items);
}
[Fact]
public async Task UserTracks_OwnerSeesUnpublishedAndCanFilterThem()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var handler = new GetTracksByUserIdQueryHandler(Database);
var all = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id), Ct);
var onlyHidden = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: false), Ct);
var onlyLive = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: true), Ct);
Assert.Equal(2, all.Value.TotalItemCount);
Assert.Equal([hidden.Id], onlyHidden.Value.Items.Select(t => t.Id));
Assert.False(onlyHidden.Value.Items[0].IsPublished == true);
Assert.Equal([live.Id], onlyLive.Value.Items.Select(t => t.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UserTracks_OtherViewersAndAnonymousOnlySeePublished(bool signedIn)
{
var (owner, other, live, _) = await SeedTracksAsync();
var result = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null, IsPublished: false), Ct);
Assert.Empty(result.Value.Items);
var all = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null), Ct);
Assert.Equal([live.Id], all.Value.Items.Select(t => t.Id));
}
[Fact]
public async Task TrackById_OnlyTheOwnerCanReadAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var handler = new GetTrackByIdQueryHandler(Database);
var asOwner = await handler.Handle(new GetTrackByIdQuery(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new GetTrackByIdQuery(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new GetTrackByIdQuery(hidden.Id), Ct);
Assert.False(asOwner.IsError);
Assert.False(asOwner.Value.IsPublished);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
}
[Fact]
public async Task StartListening_OnlyTheOwnerCanPlayAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var ticketService = Substitute.For<IStreamTicketService>();
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket", 60));
var playback = TestConfigurations.Playback(allowAnonymousListening: true);
var issuer = new TrackStreamIssuer(ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
var handler = new StartListeningCommandHandler(Database, issuer, playback, NoOpLogger<StartListeningCommandHandler>());
var asOwner = await handler.Handle(new StartListeningCommand(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new StartListeningCommand(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new StartListeningCommand(hidden.Id, null), Ct);
Assert.False(asOwner.IsError);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
Assert.Single(await Database.ListeningHistories.ToListAsync(Ct));
}
[Fact]
public async Task Genres_CountOnlyPublishedTracks()
{
await SeedTracksAsync();
var genres = await new GetGenresQueryHandler(Database).Handle(new GetGenresQuery(), Ct);
var pop = Assert.Single(genres);
Assert.Equal(Genre.Pop, pop.Genre);
Assert.Equal(1, pop.TrackCount);
}
[Fact]
public async Task ListeningHistory_AndLastListened_SkipUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await SeedAsync(
TestEntities.ListeningHistory(owner.Id, live.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, hidden.Id, DateTime.UtcNow));
var history = await new GetListeningHistoryQueryHandler(Database).Handle(new GetListeningHistoryQuery(owner.Id), Ct);
var last = await new GetLastTrackListenedByUserIdQueryHandler(Database).Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], history.Select(t => t.Id));
Assert.Equal(live.Id, last?.Id);
}
[Fact]
public async Task Likes_UnpublishedTracksCannotBeLikedAndDisappearFromTheLikedList()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var toggle = new ToggleTrackLikeCommandHandler(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
var likeHidden = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, hidden.Id), Ct);
var likeLive = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, live.Id), Ct);
await SeedAsync(new TrackLike { UserId = owner.Id, TrackId = hidden.Id });
var likedByOwner = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(owner.Id, 1, 20), Ct);
var likedByOther = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(other.Id, 1, 20), Ct);
Assert.Equal(ErrorType.NotFound, likeHidden.FirstError.Type);
Assert.False(likeLive.IsError);
Assert.Empty(likedByOwner.Items);
Assert.Equal([live.Id], likedByOther.Items.Select(t => t.Id));
}
[Fact]
public async Task Mixes_OnlyUseAndShowPublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await new GenerateMixesForUserCommandHandler(Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>())
.Handle(new GenerateMixesForUserCommand(owner.Id), Ct);
var generated = await Database.MixItems.Select(item => item.TrackId).Distinct().ToListAsync(Ct);
Assert.Equal([live.Id], generated);
var mix = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 5);
await SeedAsync(mix, TestEntities.MixItem(mix.Id, live.Id, 0), TestEntities.MixItem(mix.Id, hidden.Id, 1));
var byId = await new GetMixByIdQueryHandler(Database).Handle(new GetMixByIdQuery(owner.Id, mix.Id), Ct);
var list = await new GetMixesByUserIdQueryHandler(Database).Handle(new GetMixesByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], byId.Value.Items.Select(i => i.TrackId));
Assert.Equal(1, byId.Value.ItemCount);
var listed = list.Single(m => m.Id == mix.Id);
Assert.Equal(1, listed.ItemCount);
Assert.Equal([live.Id], listed.Items.Select(i => i.TrackId));
}
[Fact]
public async Task Albums_HideUnpublishedTracksEverywhere()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
await SeedAsync(
album,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 },
TestEntities.ListeningHistory(other.Id, hidden.Id));
var tracks = await new GetAlbumTracksQueryHandler(Database).Handle(new GetAlbumTracksQuery(album.Id, 1, 20), Ct);
var detail = await new GetAlbumByIdQueryHandler(Database).Handle(new GetAlbumByIdQuery(album.Id), Ct);
var byUser = await new GetAlbumsByUserIdQueryHandler(Database).Handle(new GetAlbumsByUserIdQuery(owner.Id, 1, 20), Ct);
var all = await new GetAlbumsQueryHandler(Database).Handle(new GetAlbumsQuery(null, 1, 20), Ct);
var recent = await new GetRecentlyListenedAlbumsQueryHandler(Database).Handle(new GetRecentlyListenedAlbumsQuery(other.Id, 10), Ct);
var ids = await new GetAlbumTrackIdsQueryHandler(Database).Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(1, byUser.Value.Items.Single().TrackCount);
Assert.Equal(1, all.Items.Single().TrackCount);
Assert.Empty(recent);
Assert.Equal([hidden.Id, live.Id], ids.Value.TrackIds);
}
[Fact]
public async Task PlayLists_HideUnpublishedTracksAndRejectAddingThem()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Public);
await SeedAsync(
playList,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = hidden.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = live.Id, Position = 1 });
var tracks = await new GetPlayListTracksQueryHandler(Database).Handle(new GetPlayListTracksQuery(playList.Id, 1, 20, owner.Id), Ct);
var detail = await new GetPlayListByIdQueryHandler(Database).Handle(new GetPlayListByIdQuery(playList.Id, other.Id), Ct);
var add = await new AddTracksToPlayListCommandHandler(Database, NoOpLogger<AddTracksToPlayListCommandHandler>())
.Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [hidden.Id]), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal(live.DurationSeconds, detail.Value.DurationSeconds);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(ErrorType.NotFound, add.FirstError.Type);
}
[Fact]
public async Task AddAlbumToPlayList_SkipsUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(
album, playList,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 });
var result = await new AddAlbumToPlayListCommandHandler(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>())
.Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), Ct);
Assert.Equal(1, result.Value.AddedCount);
Assert.Equal([live.Id], await Database.PlayListHasTracks.Select(link => link.TrackId).ToListAsync(Ct));
}
}
}
@@ -113,6 +113,32 @@ namespace Musify.Infrastructure.Tests.MassTransit
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_ProcessedWithoutOwnPicture_NeverRemovesThePresets()
{
var track = await SeedTrackAsync(
new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
},
new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.RemoveTrackProcessedAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
{
@@ -33,6 +33,24 @@ namespace Musify.Infrastructure.Tests.MassTransit
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_WithoutPicture_CompletesOnceTheAudioIsProcessed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(
new CreateTrackResourcesEvent(id, null, Guid.NewGuid(), "bucket", null, null, "audio-source", "audio", "audio-folder", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackAudioProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
{
@@ -88,6 +88,43 @@ namespace Musify.Infrastructure.Tests.MassTransit
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent);
}
[Fact]
public void CreateTrack_WithPicture_CopiesBothFilesAndConsumesBothIntents()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, IntentId, audioIntentId, "bucket", "picture-source", "picture-final", "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyPictureToFinal, ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal("picture-final", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([IntentId, audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
}
[Fact]
public void CreateTrack_WithoutPicture_SkipsThePictureCopyAndOnlyConsumesTheAudioIntent()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, null, audioIntentId, "bucket", null, null, "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.False(slip.Variables.ContainsKey(RoutingSlipVariableNames.Workflow.PictureKey));
Assert.Equal("audio-final", slip.Variables[RoutingSlipVariableNames.Workflow.AudioKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[1].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
var publish = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Null(publish["FinalPictureKey"]);
}
[Fact]
public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate()
{