A lot of changes

This commit is contained in:
2026-09-24 17:54:16 +02:00
parent ece6ce2a43
commit e84c16cf92
35 changed files with 1815 additions and 222 deletions
@@ -3,13 +3,8 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
@@ -18,16 +13,12 @@ namespace Musify.Application.Albums
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
int? ReleaseYear)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
ILogger<UpdateAlbumCommandHandler> logger)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
@@ -39,27 +30,12 @@ namespace Musify.Application.Albums
var album = found.Value;
var title = request.Title.Trim();
// This is a full replace of the plain fields; the picture has its own command.
album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description;
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear;
if (request.NewPictureIntentId is { } intentId)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
album.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
@@ -0,0 +1,60 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
/// <summary>Replaces an album's picture with a previously uploaded image; the plain fields are untouched.</summary>
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumPictureCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
album.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of album {AlbumId}", album.Id);
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}
@@ -11,7 +11,7 @@ namespace Musify.Application.Genres
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
@@ -7,6 +7,7 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
@@ -34,7 +35,7 @@ namespace Musify.Application.PlayLists
var albumTrackIds = 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 == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
@@ -54,8 +55,9 @@ namespace Musify.Application.PlayLists
return new AddAlbumToPlayListResponse(0);
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await playListTracksQuery
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in newTrackIds)
{
@@ -47,9 +47,12 @@ namespace Musify.Application.PlayLists
return Result.Success;
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId)
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
var playListTracksQuery = database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId);
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
: 0;
foreach (var trackId in toAdd)
{
@@ -39,10 +39,10 @@ namespace Musify.Application.PlayLists
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
if (request.PictureIntentId.HasValue)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
@@ -2,12 +2,8 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
@@ -16,18 +12,14 @@ namespace Musify.Application.PlayLists
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlayListVisibility? NewVisibility = null)
PlayListVisibility NewVisibility)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
ILogger<UpdatePlayListCommandHandler> logger)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
@@ -38,34 +30,11 @@ namespace Musify.Application.PlayLists
var playList = found.Value;
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playList.Name = request.NewName.Trim();
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
}
// null means "unchanged"; a blank description clears it.
if (request.NewDescription != null)
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
if (request.NewVisibility is { } visibility)
playList.Visibility = visibility;
if (request.NewPictureIntentId is { } intentId)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
}
// This is a full replace of the plain fields; the picture has its own command.
playList.Name = request.NewName.Trim();
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
playList.Visibility = request.NewVisibility;
await database.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,60 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
/// <summary>Replaces a playlist's picture with a previously uploaded image; the plain fields are untouched.</summary>
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListPictureCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken)
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
}
}
}
@@ -90,7 +90,6 @@ namespace Musify.Application.Tracks
await database.Tracks.AddAsync(trackEntity, cancellationToken);
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
await eventBus.PublishAsync(
new CreateTrackResourcesEvent(
@@ -2,31 +2,33 @@ namespace Musify.Domain.ValueObjects
{
public enum Genre
{
Pop = 0,
Rock = 1,
HipHop = 2,
RnB = 3,
Jazz = 4,
Blues = 5,
Classical = 6,
Electronic = 7,
House = 8,
Techno = 9,
Trance = 10,
Dubstep = 11,
DrumAndBass = 12,
Metal = 13,
Punk = 14,
Reggae = 15,
Reggaeton = 16,
Country = 17,
Folk = 18,
Indie = 19,
KPop = 20,
Latin = 21,
Soul = 22,
Funk = 23,
Ambient = 24,
Lofi = 25
/// <summary>Unset value (the enum default); it is not a real genre and is never stored or offered to clients.</summary>
None = 0,
Pop = 1,
Rock = 2,
HipHop = 3,
RnB = 4,
Jazz = 5,
Blues = 6,
Classical = 7,
Electronic = 8,
House = 9,
Techno = 10,
Trance = 11,
Dubstep = 12,
DrumAndBass = 13,
Metal = 14,
Punk = 15,
Reggae = 16,
Reggaeton = 17,
Country = 18,
Folk = 19,
Indie = 20,
KPop = 21,
Latin = 22,
Soul = 23,
Funk = 24,
Ambient = 25,
Lofi = 26
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GenreNone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance")
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance")
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
}
}
}
@@ -21,7 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Albums
{
public record UpdateAlbumPictureRequest(Guid PictureIntentId);
}
@@ -3,6 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums
public record UpdateAlbumRequest(
string Title,
string? Description,
int? ReleaseYear,
Guid? PictureIntentId);
int? ReleaseYear);
}
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.PlayLists
{
public record UpdatePlayListPictureRequest(Guid PictureIntentId);
}
@@ -3,8 +3,7 @@ using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.PlayLists
{
public record UpdatePlayListRequest(
string? Name,
string Name,
string? Description,
Guid? PictureIntentId,
PlayListVisibility? Visibility = null);
PlayListVisibility? Visibility);
}
@@ -94,6 +94,17 @@ namespace Musify.Api.Endpoints
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{id:guid}/picture", UpdateAlbumPicture)
.WithName("UpdateAlbumPicture")
.WithSummary("Replace an album picture with an uploaded image")
.AddEndpointFilter<ValidationFilter<UpdateAlbumPictureRequest>>()
.RequireAuthorization()
.Produces<AlbumApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{id:guid}", DeleteAlbum)
.WithName("DeleteAlbum")
.WithSummary("Delete an album")
@@ -229,7 +240,21 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateAlbumPicture(
IMediator mediator,
CurrentUser currentUser,
Guid id,
UpdateAlbumPictureRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdateAlbumPictureCommand(currentUser.RequiredId, id, request.PictureIntentId),
cancellationToken);
return result.ToHttpResult();
@@ -68,6 +68,17 @@ namespace Musify.Api.Endpoints
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{id:guid}/picture", UpdatePlayListPicture)
.WithName("UpdatePlayListPicture")
.WithSummary("Replace a playlist picture with an uploaded image")
.AddEndpointFilter<ValidationFilter<UpdatePlayListPictureRequest>>()
.RequireAuthorization()
.Produces<PlayListApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{id:guid}", DeletePlayList)
.WithName("DeletePlayList")
.WithSummary("Delete a playlist")
@@ -185,7 +196,21 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility),
new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.Visibility!.Value),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePlayListPicture(
IMediator mediator,
CurrentUser currentUser,
Guid id,
UpdatePlayListPictureRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdatePlayListPictureCommand(currentUser.RequiredId, id, request.PictureIntentId),
cancellationToken);
return result.ToHttpResult();
@@ -7,7 +7,7 @@ namespace Musify.Api.Models
{
public static bool TryParse(string? value, out GenreParameter result)
{
if (Enum.TryParse<Genre>(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre))
if (Enum.TryParse<Genre>(value, ignoreCase: true, out var genre) && genre != Genre.None && Enum.IsDefined(genre))
{
result = new GenreParameter(genre);
return true;
@@ -0,0 +1,14 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Albums;
namespace Musify.Api.Validators.Albums
{
public sealed class UpdateAlbumPictureRequestValidator : AbstractValidator<UpdateAlbumPictureRequest>
{
public UpdateAlbumPictureRequestValidator()
{
RuleFor(x => x.PictureIntentId)
.NotEmpty();
}
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists
{
public sealed class UpdatePlayListPictureRequestValidator : AbstractValidator<UpdatePlayListPictureRequest>
{
public UpdatePlayListPictureRequestValidator()
{
RuleFor(x => x.PictureIntentId)
.NotEmpty();
}
}
}
@@ -9,16 +9,14 @@ namespace Musify.Api.Validators.PlayLists
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(Limits.PlayListName)
.When(x => x.Name != null);
.MaximumLength(Limits.PlayListName);
RuleFor(x => x.Description)
.MaximumLength(Limits.Description)
.When(x => x.Description != null);
.MaximumLength(Limits.Description);
RuleFor(x => x.Visibility)
.IsInEnum()
.When(x => x.Visibility != null);
.NotNull()
.IsInEnum();
}
}
}
@@ -22,6 +22,11 @@ namespace Musify.Api.Validators.Tracks
.NotEmpty()
.WithMessage("At least one tag is required.");
RuleFor(x => x.Tags)
.Must(tags => !tags.Contains(Genre.None))
.When(x => x.Tags is { Count: > 0 })
.WithMessage("'None' is not a valid tag.");
RuleFor(x => x.Tags)
.Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0)
.When(x => x.Tags is { Count: > 0 })
@@ -111,6 +111,37 @@ namespace Musify.Api.Tests.Endpoints
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task UpdateAlbumPicture_NotTheOwner_ReturnsForbidden()
{
var owner = await CreateUserAsync();
var stranger = await CreateUserAsync();
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);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task UpdateAlbumPicture_EmptyIntent_ReturnsBadRequest()
{
var owner = await CreateUserAsync();
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);
var response = await ownerClient.PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
{
@@ -63,7 +63,7 @@ namespace Musify.Api.Tests.Endpoints
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Equal(Enum.GetValues<Genre>().Count(genre => genre != Genre.None), body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
@@ -96,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct);
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, PlayListVisibility.Public), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
@@ -120,13 +120,50 @@ namespace Musify.Api.Tests.Endpoints
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct);
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, PlayListVisibility.Private), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayListApplicationResponse>(JsonOptions, Ct);
Assert.Equal("Renamed", body!.Name);
}
[Fact]
public async Task UpdatePlayList_MissingVisibility_ReturnsBadRequest()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task UpdatePlayListPicture_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/playlists/{playList.Id}/picture", new UpdatePlayListPictureRequest(Guid.NewGuid()), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task UpdatePlayListPicture_EmptyIntent_ReturnsBadRequest()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
$"/playlists/{playList.Id}/picture", new UpdatePlayListPictureRequest(Guid.Empty), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetPlayListById_NotAGuid_ReturnsNotFound()
{
@@ -1,24 +1,15 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Album());
NoOpLogger<UpdateAlbumCommandHandler>());
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
@@ -27,7 +18,7 @@ namespace Musify.Application.Tests.Albums
var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -42,32 +33,22 @@ namespace Musify.Application.Tests.Albums
}
[Fact]
public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent()
public async Task Handle_BlankDescription_ClearsIt()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Old Title");
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
var album = TestEntities.Album(owner.Id, title: "Title", description: "Old");
await SeedAsync(owner, album);
var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Title", " ", null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
Assert.Null(result.Value.Description);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null);
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -82,7 +63,7 @@ namespace Musify.Application.Tests.Albums
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -0,0 +1,69 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class UpdateAlbumPictureCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdateAlbumPictureCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumPictureCommandHandler>(),
TestConfigurations.Album());
[Fact]
public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Keep Title", description: "Keep description", releaseYear: 2020);
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
var result = await CreateHandler().Handle(new UpdateAlbumPictureCommand(owner.Id, album.Id, intent.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Keep Title", result.Value.Title);
Assert.Equal("Keep description", result.Value.Description);
Assert.Equal(2020, result.Value.ReleaseYear);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new UpdateAlbumPictureCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[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 UpdateAlbumPictureCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
}
}
@@ -11,11 +11,11 @@ namespace Musify.Application.Tests.Genres
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
public async Task Handle_ReturnsEveryGenreExceptNone()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
Assert.Equal(Enum.GetValues<Genre>().Where(genre => genre != Genre.None), result.Select(r => r.Genre));
}
[Fact]
@@ -1,24 +1,15 @@
using ErrorOr;
using Musify.Application.Contracts;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists
{
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.PlayList());
NoOpLogger<UpdatePlayListCommandHandler>());
[Fact]
public async Task Handle_Owner_RenamesPlayList()
@@ -27,7 +18,7 @@ namespace Musify.Application.Tests.PlayLists
var playList = TestEntities.PlayList(owner.Id, "Old Name");
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -38,7 +29,7 @@ namespace Musify.Application.Tests.PlayLists
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -53,7 +44,7 @@ namespace Musify.Application.Tests.PlayLists
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, Musify.Domain.ValueObjects.PlayListVisibility.Private);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -67,7 +58,7 @@ namespace Musify.Application.Tests.PlayLists
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "Same Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Public);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -76,59 +67,35 @@ namespace Musify.Application.Tests.PlayLists
}
[Fact]
public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged()
public async Task Handle_ReplacesNameDescriptionAndVisibility()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description", visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " New Name ", "New description", Musify.Domain.ValueObjects.PlayListVisibility.Private);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
Assert.Equal("New Name", result.Value.Name);
Assert.Equal("New description", result.Value.Description);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility);
}
[Fact]
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
public async Task Handle_MissingDescription_ClearsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "Original Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Original Name", result.Value.Name);
Assert.Null(result.Value.Description);
}
[Fact]
public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp");
await SeedAsync(owner, playList, intent);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Null(result.Value.SmallImageKeyName);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
}
}
@@ -0,0 +1,69 @@
using ErrorOr;
using Musify.Application.Contracts;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists
{
public sealed class UpdatePlayListPictureCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdatePlayListPictureCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListPictureCommandHandler>(),
TestConfigurations.PlayList());
[Fact]
public async Task Handle_ValidPictureIntent_SetsPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Keep Name", "Keep description");
playList.Pictures = null;
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp");
await SeedAsync(owner, playList, intent);
var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(owner.Id, playList.Id, intent.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Keep Name", result.Value.Name);
Assert.Equal("Keep description", result.Value.Description);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
}
}