Deduplicate picture flows: shared upload issuer, cover query and picture source change

This commit is contained in:
2026-09-24 12:04:54 +02:00
parent 4b147d9ee3
commit 75d0c0817e
55 changed files with 667 additions and 1163 deletions
@@ -16,11 +16,9 @@ public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
private CreateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
TestConfigurations.Album());
[Fact]
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
@@ -1,52 +0,0 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase
{
private GetAlbumCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
}
@@ -20,14 +20,17 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestAlbumPictureUploadCommandHandler(
Database,
new UploadIntentValidator(Database, storageService),
storageService,
NoOpLogger<RequestAlbumPictureUploadCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Album());
}
[Fact]
@@ -16,11 +16,9 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
TestConfigurations.Album());
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
@@ -0,0 +1,104 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Pictures;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Pictures;
public sealed class GetCoverQueryHandlerTests : HandlerTestBase
{
private GetCoverQueryHandler CreateHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_ProcessedPlayList_ReturnsRequestedSize()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.EndsWith("large.webp", result.Value.Key);
}
[Theory]
[InlineData(CoverOwner.Track)]
[InlineData(CoverOwner.Album)]
[InlineData(CoverOwner.PlayList)]
public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner)
{
var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
[Fact]
public async Task Handle_RemovingAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.LifeCycleStatus = LifeCycleStatus.Removing;
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -16,11 +16,9 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
private CreatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
TestConfigurations.PlayList());
[Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
@@ -1,50 +0,0 @@
using ErrorOr;
using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
{
private GetPlayListCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListWithoutPictures_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -20,14 +20,17 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestPlayListPictureUploadCommandHandler(
Database,
new UploadIntentValidator(Database, storageService),
storageService,
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.PlayList());
}
[Fact]
@@ -16,11 +16,9 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
TestConfigurations.PlayList());
[Fact]
public async Task Handle_Owner_RenamesPlayList()
@@ -1,4 +1,5 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
@@ -13,7 +14,8 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private readonly Domain.Entities.User owner = TestEntities.User();
private UploadIntentValidator CreateValidator() => new(Database, storageService);
private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) =>
new(Database, storageService, config ?? TestConfigurations.UploadIntent());
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
@@ -25,7 +27,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(intent.Id, result.Value.Id);
@@ -37,7 +39,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
@@ -45,7 +47,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
[Fact]
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
@@ -57,7 +59,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
@@ -68,7 +70,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
@@ -79,7 +81,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
@@ -94,7 +96,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
@@ -112,7 +114,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
@@ -122,7 +124,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
{
await SeedAsync(owner);
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
@@ -134,7 +136,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
@@ -149,7 +151,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
@@ -24,11 +24,9 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
return new CreateTrackCommandHandler(
Database,
eventBus,
new UploadIntentValidator(Database, storageService),
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Track(),
TestConfigurations.UploadIntent());
TestConfigurations.Track());
}
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
@@ -1,51 +0,0 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
{
private GetTrackCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("medium", "medium.webp")]
[InlineData("large", "large.webp")]
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -21,14 +21,19 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler(
Database,
new UploadIntentValidator(Database, storageService),
storageService,
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
TestConfigurations.Track(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
config);
}
[Fact]