Merge backend library endpoints
This commit is contained in:
@@ -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.");
|
||||
|
||||
UploadIntent? pictureIntent = null;
|
||||
if (request.PictureIntentId is { } pictureIntentId)
|
||||
{
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
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();
|
||||
|
||||
return new TrackUploadUrlsResponse(
|
||||
picture.IntentId,
|
||||
var items = audioUploads
|
||||
.Select((audio, index) => new TrackAudioUpload(
|
||||
audio.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
picture.Key,
|
||||
picture.ObjectName,
|
||||
picture.ContentType,
|
||||
picture.UploadUrl,
|
||||
hasPicture ? pictures[index].IntentId : null,
|
||||
audio.Key,
|
||||
audio.ObjectName,
|
||||
audio.ContentType,
|
||||
audio.UploadUrl,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
audio.UploadUrl))
|
||||
.ToList();
|
||||
|
||||
var firstPicture = pictures.Count > 0 ? pictures[0] : null;
|
||||
var firstAudio = audioUploads[0];
|
||||
|
||||
return new TrackUploadUrlsResponse(
|
||||
firstPicture?.IntentId,
|
||||
firstAudio.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
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
|
||||
}
|
||||
}
|
||||
+3
@@ -16,6 +16,8 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
||||
{
|
||||
var arguments = executeContext.Arguments;
|
||||
|
||||
if (arguments.FinalPictureKey != null)
|
||||
{
|
||||
await publishEndpoint.Publish(
|
||||
new UpdateTrackPictureEvent(
|
||||
arguments.TrackId,
|
||||
@@ -23,6 +25,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
||||
arguments.FinalPictureKey,
|
||||
arguments.Sizes),
|
||||
executeContext.CancellationToken);
|
||||
}
|
||||
|
||||
await publishEndpoint.Publish(
|
||||
new UpdateTrackAudioEvent(
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+18
-6
@@ -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;
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+1090
File diff suppressed because it is too large
Load Diff
+42
@@ -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);
|
||||
}
|
||||
+11
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-8
@@ -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)
|
||||
When(x => x.PictureFileType != null || x.PictureContentType != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PictureFileType!)
|
||||
.MustBeFileType(Uploads.PictureFileTypes);
|
||||
|
||||
RuleFor(x => x.PictureContentType)
|
||||
RuleFor(x => x.PictureContentType!)
|
||||
.MustBeContentType(Uploads.PictureContentTypes);
|
||||
|
||||
RuleFor(x => x.AudioFileType)
|
||||
.MustBeFileType(Uploads.AudioFileTypes);
|
||||
|
||||
RuleFor(x => x.AudioContentType)
|
||||
.MustBeContentType(Uploads.AudioContentTypes);
|
||||
|
||||
RuleFor(x => x.ExpectedPictureSizeBytes)
|
||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||
});
|
||||
|
||||
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.AudioContentType)
|
||||
.Null()
|
||||
.WithMessage("Use either the audio fields or the audios list, not both.");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,8 +118,8 @@
|
||||
},
|
||||
"UploadIntent": {
|
||||
"UploadUrlExpiresInSeconds": 120,
|
||||
"MaxActiveUploadIntentsPerUser": 5,
|
||||
"MaxActiveUploadBytesPerUser": 209715200,
|
||||
"MaxActiveUploadIntentsPerUser": 50,
|
||||
"MaxActiveUploadBytesPerUser": 1073741824,
|
||||
"MaxUploadBytes": 104857600,
|
||||
"DefaultExpectedPictureSizeBytes": 10485760,
|
||||
"DefaultExpectedAudioSizeBytes": 104857600,
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user