diff --git a/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs b/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs index b6881b6..5fc3439 100644 --- a/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs +++ b/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs @@ -14,10 +14,13 @@ namespace Musify.Application.PlayLists.Handlers { var playList = await database.PlayLists .AsNoTracking() + .Where(p => p.Id == request.Id) .Select(p => PlayListApplicationResponse.FromEntity(p)) - .SingleOrDefaultAsync(p => p.Id == request.Id, cancellationToken); + .SingleOrDefaultAsync(cancellationToken); - return playList ?? Result.NotFound(); + return playList is null + ? Result.NotFound() + : Result.Success(playList); } } } \ No newline at end of file diff --git a/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs b/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs index 52e48c0..8fa199b 100644 --- a/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs +++ b/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs @@ -13,10 +13,13 @@ namespace Musify.Application.Tracks.Handlers public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) .Select(t => TrackApplicationResponse.FromEntity(t)) - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + .SingleOrDefaultAsync(cancellationToken); - return track ?? Result.NotFound(); + return track is null + ? Result.NotFound() + : Result.Success(track); } } } \ No newline at end of file diff --git a/Application/Users/Handlers/GetUserByIdQueryHandler.cs b/Application/Users/Handlers/GetUserByIdQueryHandler.cs index 9abc9fc..c99c799 100644 --- a/Application/Users/Handlers/GetUserByIdQueryHandler.cs +++ b/Application/Users/Handlers/GetUserByIdQueryHandler.cs @@ -14,10 +14,13 @@ namespace Musify.Application.Users.Handlers { var user = await database.Users .AsNoTracking() + .Where(u => u.Id == request.Id) .Select(u => UserApplicationResponse.FromEntity(u)) - .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + .SingleOrDefaultAsync(cancellationToken); - return user ?? Result.NotFound(); + return user is null + ? Result.NotFound() + : Result.Success(user); } } } \ No newline at end of file diff --git a/MusifyBackend.slnx b/MusifyBackend.slnx index c92b66b..386f50d 100644 --- a/MusifyBackend.slnx +++ b/MusifyBackend.slnx @@ -1,4 +1,8 @@ + + + + diff --git a/Tests/Musify.Application.Tests/Configuration/RoutesTests.cs b/Tests/Musify.Application.Tests/Configuration/RoutesTests.cs new file mode 100644 index 0000000..a0c0cbf --- /dev/null +++ b/Tests/Musify.Application.Tests/Configuration/RoutesTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Application.Tests.Configuration; + +public sealed class RoutesTests +{ + private static readonly Guid UserId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + [Fact] + public void TrackRoutes_default_picture_and_audio_paths() + { + var routes = new TrackRoutes(); + + routes.OriginalPicturesPath.Should().Be("Tracks/OriginalPictures"); + routes.OriginalAudiosPath.Should().Be("Tracks/OriginalAudios"); + routes.ProcessedAudiosPath.Should().Be("Tracks/ProcessedAudios"); + } + + [Fact] + public void TrackRoutes_BuildOriginalPicturePath_with_user_nests_under_uploads() + { + var routes = new TrackRoutes(); + + routes.BuildOriginalPicturePath(UserId, "cover.webp") + .Should().Be($"uploads/{UserId}/Tracks/OriginalPictures/cover.webp"); + } + + [Fact] + public void TrackRoutes_BuildOriginalAudioPath_with_user_nests_under_uploads() + { + var routes = new TrackRoutes(); + + routes.BuildOriginalAudioPath(UserId, "song.mp3") + .Should().Be($"uploads/{UserId}/Tracks/OriginalAudios/song.mp3"); + } + + [Fact] + public void TrackRoutes_BuildTempPicturePath_uses_prefix_user_and_parent_folder() + { + var routes = new TrackRoutes(); + + routes.BuildTempPicturePath("temp", UserId, "obj.png") + .Should().Be($"temp/{UserId}/Tracks/obj.png"); + } + + [Fact] + public void CombineKey_trims_slashes_and_whitespace_per_segment() + { + var routes = new TrackRoutes(); + + var result = routes.BuildTempPicturePath(" /temp/ ", UserId, "/obj.png\\"); + + result.Should().Be($"temp/{UserId}/Tracks/obj.png"); + } + + [Fact] + public void CombineKey_drops_empty_and_whitespace_segments() + { + var routes = new TrackRoutes(); + + var result = routes.BuildTempPicturePath(" ", UserId, "obj.png"); + + result.Should().Be($"{UserId}/Tracks/obj.png"); + } + + [Fact] + public void PlayListRoutes_BuildOriginalPicturePath_with_user_nests_under_uploads() + { + var routes = new PlayListRoutes(); + + routes.BuildOriginalPicturePath(UserId, "cover.webp") + .Should().Be($"uploads/{UserId}/PlayLists/OriginalPictures/cover.webp"); + } + + [Fact] + public void PlayListRoutes_size_paths_use_parent_folder() + { + var routes = new PlayListRoutes(); + + routes.SmallPicturesPath.Should().Be("PlayLists/SmallPictures"); + routes.MediumPicturesPath.Should().Be("PlayLists/MediumPictures"); + routes.LargePicturesPath.Should().Be("PlayLists/LargePictures"); + } +} diff --git a/Tests/Musify.Application.Tests/Domain/TrackTests.cs b/Tests/Musify.Application.Tests/Domain/TrackTests.cs new file mode 100644 index 0000000..f56505d --- /dev/null +++ b/Tests/Musify.Application.Tests/Domain/TrackTests.cs @@ -0,0 +1,89 @@ +using FluentAssertions; +using Musify.Domain.ValueObjects; +using Musify.Application.Tests.Infrastructure; +using Xunit; + +namespace Musify.Application.Tests.Domain; + +public sealed class TrackTests +{ + [Fact] + public void IsPicturesProcessed_is_true_only_when_completed() + { + var track = TestData.Track(); + + track.PicturesProcessingStatus = ProcessingStatus.Completed; + track.IsPicturesProcessed.Should().BeTrue(); + + track.PicturesProcessingStatus = ProcessingStatus.Processing; + track.IsPicturesProcessed.Should().BeFalse(); + + track.PicturesProcessingStatus = ProcessingStatus.Failed; + track.IsPicturesProcessed.Should().BeFalse(); + } + + [Fact] + public void IsAudioProcessed_is_true_only_when_completed() + { + var track = TestData.Track(); + + track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; + track.IsAudioProcessed.Should().BeTrue(); + + track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending; + track.IsAudioProcessed.Should().BeFalse(); + } + + [Fact] + public void Defaults_are_active_pending_and_zero_retries() + { + var track = new Musify.Domain.Entities.Track + { + Title = "t", + NormalizedTitle = "T", + OriginalPictureName = "p.webp", + OriginalAudioName = "a.mp3", + }; + + track.LifeCycleStatus.Should().Be(LifeCycleStatus.Active); + track.PicturesProcessingStatus.Should().Be(ProcessingStatus.Pending); + track.AudioTranscodeProcessingStatus.Should().Be(ProcessingStatus.Pending); + track.RetryCount.Should().Be(0); + track.Id.Should().NotBe(Guid.Empty); + } +} + +public sealed class EnumValueRegressionTests +{ + [Fact] + public void ProcessingStatus_values_are_stable() + { + ((int)ProcessingStatus.Pending).Should().Be(0); + ((int)ProcessingStatus.Processing).Should().Be(1); + ((int)ProcessingStatus.Completed).Should().Be(2); + ((int)ProcessingStatus.Failed).Should().Be(3); + } + + [Fact] + public void LifeCycleStatus_values_are_stable() + { + ((int)LifeCycleStatus.Active).Should().Be(0); + ((int)LifeCycleStatus.Removing).Should().Be(1); + } + + [Fact] + public void UploadIntentStatus_values_are_stable() + { + ((int)UploadIntentStatus.Issued).Should().Be(0); + ((int)UploadIntentStatus.Consumed).Should().Be(1); + ((int)UploadIntentStatus.Expired).Should().Be(2); + } + + [Fact] + public void UploadIntentPurpose_values_are_stable() + { + ((int)UploadIntentPurpose.PlayListPicture).Should().Be(0); + ((int)UploadIntentPurpose.TrackPicture).Should().Be(1); + ((int)UploadIntentPurpose.TrackAudio).Should().Be(2); + } +} diff --git a/Tests/Musify.Application.Tests/Extensions/ResultExtensionsTests.cs b/Tests/Musify.Application.Tests/Extensions/ResultExtensionsTests.cs new file mode 100644 index 0000000..72ef974 --- /dev/null +++ b/Tests/Musify.Application.Tests/Extensions/ResultExtensionsTests.cs @@ -0,0 +1,74 @@ +using Ardalis.Result; +using FluentAssertions; +using Musify.Application.Extensions; +using Xunit; + +namespace Musify.Application.Tests.Extensions; + +public sealed class ResultExtensionsTests +{ + [Fact] + public void NotFound_maps_to_NotFound_and_preserves_errors() + { + var source = Result.NotFound("missing"); + + var mapped = source.As(); + + mapped.Status.Should().Be(ResultStatus.NotFound); + mapped.Errors.Should().Contain("missing"); + } + + [Fact] + public void Conflict_maps_to_Conflict() + { + Result.Conflict("dup").As().Status.Should().Be(ResultStatus.Conflict); + } + + [Fact] + public void Unauthorized_maps_to_Unauthorized() + { + Result.Unauthorized().As().Status.Should().Be(ResultStatus.Unauthorized); + } + + [Fact] + public void Forbidden_maps_to_Forbidden() + { + Result.Forbidden().As().Status.Should().Be(ResultStatus.Forbidden); + } + + [Fact] + public void Invalid_maps_to_Invalid_and_preserves_validation_errors() + { + var source = Result.Invalid(new ValidationError("field", "bad")); + + var mapped = source.As(); + + mapped.Status.Should().Be(ResultStatus.Invalid); + mapped.ValidationErrors.Should().ContainSingle(e => e.ErrorMessage == "bad"); + } + + [Fact] + public void Error_maps_to_Error() + { + Result.Error("boom").As().Status.Should().Be(ResultStatus.Error); + } + + [Theory] + [InlineData(ResultStatus.Ok)] + [InlineData(ResultStatus.Created)] + [InlineData(ResultStatus.NoContent)] + public void Successful_results_throw_when_converted(ResultStatus status) + { + Result source = status switch + { + ResultStatus.Ok => Result.Success(1), + ResultStatus.Created => Result.Created(1), + ResultStatus.NoContent => Result.NoContent(), + _ => throw new InvalidOperationException() + }; + + var act = () => source.As(); + + act.Should().Throw(); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/CreatePlayListCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/CreatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..6f60132 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/CreatePlayListCommandHandlerTests.cs @@ -0,0 +1,105 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class CreatePlayListCommandHandlerTests +{ + private readonly IEventBus _eventBus = Substitute.For(); + private readonly IStorageService _storage = Substitute.For(); + + private CreatePlayListCommandHandler CreateSut(TestDatabase db) => + new( + _eventBus, + db, + new UploadIntentValidator(db, _storage), + NullLogger.Instance, + TestData.StorageConfig(), + TestData.PlayListConfig(), + TestData.UploadIntentConfig()); + + [Fact] + public async Task When_user_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle( + new CreatePlayListCommand(Guid.NewGuid(), "List", "desc", null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task Without_picture_intent_uses_preset_and_does_not_publish() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreatePlayListCommand(user.Id, " Chill Vibes ", "desc", null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Created); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.UserId == user.Id); + persisted.OriginalPictureName.Should().Be(TestData.PlayListConfig().Routes.PresetOriginalPicture); + persisted.NormalizedName.Should().Be("CHILL VIBES"); + } + + [Fact] + public async Task With_valid_picture_intent_publishes_event_with_intent_object_name() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + var intent = TestData.UploadIntent(user.Id, UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); + ctx.Database.Users.Add(user); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.HeadObjectAsync(intent.Bucket, intent.Key, Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 500)); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreatePlayListCommand(user.Id, "List", "desc", intent.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Created); + await _eventBus.Received(1).PublishAsync( + Arg.Is(e => e.PictureIntentId == intent.Id), + Arg.Any()); + + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.UserId == user.Id); + persisted.OriginalPictureName.Should().Be("cover.webp"); + } + + [Fact] + public async Task With_invalid_picture_intent_propagates_error_and_does_not_persist() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreatePlayListCommand(user.Id, "List", "desc", Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + (await ctx.NewContext().PlayLists.CountAsync()).Should().Be(0); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/CreateTrackCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/CreateTrackCommandHandlerTests.cs new file mode 100644 index 0000000..640b45b --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/CreateTrackCommandHandlerTests.cs @@ -0,0 +1,141 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Handlers; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class CreateTrackCommandHandlerTests +{ + private readonly IEventBus _eventBus = Substitute.For(); + private readonly IStorageService _storage = Substitute.For(); + + private CreateTrackCommandHandler CreateSut(TestDatabase db) => + new( + db, + _eventBus, + new UploadIntentValidator(db, _storage), + NullLogger.Instance, + TestData.StorageConfig(), + TestData.TrackConfig(), + TestData.UploadIntentConfig()); + + private void StorageHasObject() => + _storage.HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("application/octet-stream", 500)); + + private static async Task<(UploadIntent picture, UploadIntent audio)> SeedIntentsAsync( + SqliteTestContext ctx, Guid userId) + { + var picture = TestData.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "pic.png", key: $"temp/{userId}/pic.png"); + var audio = TestData.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", key: $"temp/{userId}/song.mp3"); + ctx.Database.UploadIntents.AddRange(picture, audio); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + return (picture, audio); + } + + [Fact] + public async Task When_user_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle( + new CreateTrackCommand(Guid.NewGuid(), "Song", Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task When_picture_intent_invalid_propagates_and_creates_no_track() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreateTrackCommand(user.Id, "Song", Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + (await ctx.NewContext().Tracks.CountAsync()).Should().Be(0); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task When_audio_intent_invalid_propagates() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + var picture = TestData.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "pic.png", key: $"temp/{user.Id}/pic.png"); + ctx.Database.Users.Add(user); + ctx.Database.UploadIntents.Add(picture); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + StorageHasObject(); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreateTrackCommand(user.Id, "Song", picture.Id, Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task Happy_path_creates_track_publishes_event_and_normalizes_title() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + var (picture, audio) = await SeedIntentsAsync(ctx, user.Id); + StorageHasObject(); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Created); + await _eventBus.Received(1).PublishAsync( + Arg.Is(e => + e.PictureIntentId == picture.Id && e.AudioIntentId == audio.Id), + Arg.Any()); + + var persisted = await ctx.NewContext().Tracks.SingleAsync(); + persisted.NormalizedTitle.Should().Be("MY SONG"); + persisted.OriginalPictureName.Should().Be("pic.png"); + persisted.OriginalAudioName.Should().Be("song.mp3"); + } + + [Fact] + public async Task When_publish_throws_returns_error_and_does_not_persist() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + var (picture, audio) = await SeedIntentsAsync(ctx, user.Id); + StorageHasObject(); + + _eventBus.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("bus down")); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Error); + (await ctx.NewContext().Tracks.CountAsync()).Should().Be(0); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/CreateUserCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/CreateUserCommandHandlerTests.cs new file mode 100644 index 0000000..789a6dc --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/CreateUserCommandHandlerTests.cs @@ -0,0 +1,44 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Users.Commands; +using Musify.Application.Users.Handlers; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class CreateUserCommandHandlerTests +{ + [Fact] + public async Task When_user_already_exists_returns_conflict() + { + await using var ctx = new SqliteTestContext(); + var existing = TestData.User(); + ctx.Database.Users.Add(existing); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new CreateUserCommandHandler(ctx.Database, NullLogger.Instance); + var result = await sut.Handle(new CreateUserCommand(existing.Id, "Bob", null, null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Conflict); + } + + [Fact] + public async Task Happy_path_creates_user_and_persists() + { + await using var ctx = new SqliteTestContext(); + var id = Guid.NewGuid(); + + var sut = new CreateUserCommandHandler(ctx.Database, NullLogger.Instance); + var result = await sut.Handle(new CreateUserCommand(id, "Bob", "Robert", "Smith"), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Created); + result.Value.Name.Should().Be("Bob"); + + var persisted = await ctx.NewContext().Users.SingleAsync(u => u.Id == id); + persisted.NormalizedName.Should().Be("BOB"); + persisted.FirstName.Should().Be("Robert"); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/DeletePlayListCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/DeletePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..9b69142 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/DeletePlayListCommandHandlerTests.cs @@ -0,0 +1,88 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class DeletePlayListCommandHandlerTests +{ + private readonly IEventBus _eventBus = Substitute.For(); + + private DeletePlayListCommandHandler CreateSut(TestDatabase db) => + new(db, _eventBus, NullLogger.Instance); + + [Fact] + public async Task When_playlist_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle(new DeletePlayListCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task When_user_not_owner_returns_unauthorized() + { + await using var ctx = new SqliteTestContext(); + var playList = TestData.PlayList(Guid.NewGuid()); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeletePlayListCommand(Guid.NewGuid(), playList.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Unauthorized); + } + + [Fact] + public async Task Happy_path_marks_removing_publishes_event_and_returns_no_content() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeletePlayListCommand(userId, playList.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NoContent); + await _eventBus.Received(1).PublishAsync( + Arg.Is(e => e.PlayListId == playList.Id && e.UserId == userId), + Arg.Any()); + + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.Id == playList.Id); + persisted.LifeCycleStatus.Should().Be(LifeCycleStatus.Removing); + } + + [Fact] + public async Task When_publish_throws_returns_error_and_does_not_persist() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _eventBus.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("bus down")); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeletePlayListCommand(userId, playList.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Error); + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.Id == playList.Id); + persisted.LifeCycleStatus.Should().Be(LifeCycleStatus.Active); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/DeleteTrackCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/DeleteTrackCommandHandlerTests.cs new file mode 100644 index 0000000..e81a555 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/DeleteTrackCommandHandlerTests.cs @@ -0,0 +1,108 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Handlers; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class DeleteTrackCommandHandlerTests +{ + private readonly IEventBus _eventBus = Substitute.For(); + + private DeleteTrackCommandHandler CreateSut(TestDatabase db) => + new(db, _eventBus, NullLogger.Instance); + + private static async Task<(Guid trackId, Guid userId)> SeedOwnedTrackAsync( + SqliteTestContext ctx, ProcessingStatus audioStatus) + { + var userId = Guid.NewGuid(); + var track = TestData.Track(audioStatus: audioStatus); + ctx.Database.Tracks.Add(track); + ctx.Database.UserHasTracks.Add(new UserHasTrack { UserId = userId, TrackId = track.Id }); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + return (track.Id, userId); + } + + [Fact] + public async Task When_track_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle(new DeleteTrackCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task When_user_not_owner_returns_unauthorized() + { + await using var ctx = new SqliteTestContext(); + var track = TestData.Track(); + ctx.Database.Tracks.Add(track); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeleteTrackCommand(Guid.NewGuid(), track.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Unauthorized); + } + + [Theory] + [InlineData(ProcessingStatus.Pending)] + [InlineData(ProcessingStatus.Processing)] + public async Task When_audio_still_processing_returns_conflict(ProcessingStatus status) + { + await using var ctx = new SqliteTestContext(); + var (trackId, userId) = await SeedOwnedTrackAsync(ctx, status); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeleteTrackCommand(userId, trackId), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Conflict); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Happy_path_marks_removing_publishes_event_and_returns_no_content() + { + await using var ctx = new SqliteTestContext(); + var (trackId, userId) = await SeedOwnedTrackAsync(ctx, ProcessingStatus.Completed); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeleteTrackCommand(userId, trackId), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NoContent); + await _eventBus.Received(1).PublishAsync( + Arg.Is(e => e.TrackId == trackId), Arg.Any()); + + var persisted = await ctx.NewContext().Tracks.SingleAsync(t => t.Id == trackId); + persisted.LifeCycleStatus.Should().Be(LifeCycleStatus.Removing); + } + + [Fact] + public async Task When_publish_throws_returns_error_and_does_not_persist() + { + await using var ctx = new SqliteTestContext(); + var (trackId, userId) = await SeedOwnedTrackAsync(ctx, ProcessingStatus.Completed); + + _eventBus.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("bus down")); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(new DeleteTrackCommand(userId, trackId), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Error); + var persisted = await ctx.NewContext().Tracks.SingleAsync(t => t.Id == trackId); + persisted.LifeCycleStatus.Should().Be(LifeCycleStatus.Active); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/GetByIdQueryHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/GetByIdQueryHandlerTests.cs new file mode 100644 index 0000000..7c29582 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/GetByIdQueryHandlerTests.cs @@ -0,0 +1,93 @@ +using Ardalis.Result; +using FluentAssertions; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.PlayLists.Queries; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Handlers; +using Musify.Application.Tracks.Queries; +using Musify.Application.Users.Handlers; +using Musify.Application.Users.Queries; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class GetByIdQueryHandlerTests +{ + [Fact] + public async Task GetTrackById_returns_track_when_found() + { + await using var ctx = new SqliteTestContext(); + var track = TestData.Track(title: "Found"); + ctx.Database.Tracks.Add(track); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetTrackByIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTrackByIdQuery(track.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Title.Should().Be("Found"); + } + + [Fact] + public async Task GetTrackById_returns_not_found_when_missing() + { + await using var ctx = new SqliteTestContext(); + var sut = new GetTrackByIdQueryHandler(ctx.Database); + + var result = await sut.Handle(new GetTrackByIdQuery(Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task GetPlayListById_returns_playlist_when_found() + { + await using var ctx = new SqliteTestContext(); + var playList = TestData.PlayList(Guid.NewGuid(), name: "MyList"); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetPlayListByIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetPlayListByIdQuery(playList.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("MyList"); + } + + [Fact] + public async Task GetPlayListById_returns_not_found_when_missing() + { + await using var ctx = new SqliteTestContext(); + var sut = new GetPlayListByIdQueryHandler(ctx.Database); + + var result = await sut.Handle(new GetPlayListByIdQuery(Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task GetUserById_returns_user_when_found() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(name: "Carol"); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetUserByIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetUserByIdQuery(user.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("Carol"); + } + + [Fact] + public async Task GetUserById_returns_not_found_when_missing() + { + await using var ctx = new SqliteTestContext(); + var sut = new GetUserByIdQueryHandler(ctx.Database); + + var result = await sut.Handle(new GetUserByIdQuery(Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/GetTracksByUserIdQueryHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/GetTracksByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..0352e47 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/GetTracksByUserIdQueryHandlerTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Handlers; +using Musify.Application.Tracks.Queries; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class GetTracksByUserIdQueryHandlerTests +{ + private static async Task SeedTrackForUserAsync(SqliteTestContext ctx, Guid userId, string title, DateTime created) + { + var track = TestData.Track(title: title); + track.CreatedDate = created; + ctx.Database.Tracks.Add(track); + ctx.Database.UserHasTracks.Add(new UserHasTrack { UserId = userId, TrackId = track.Id }); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + } + + [Fact] + public async Task Returns_only_tracks_owned_by_the_user_ordered_by_created_date() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var otherUser = Guid.NewGuid(); + await SeedTrackForUserAsync(ctx, userId, "Beta", new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)); + await SeedTrackForUserAsync(ctx, userId, "Alpha", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + await SeedTrackForUserAsync(ctx, otherUser, "Gamma", new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)); + + var sut = new GetTracksByUserIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTracksByUserIdQuery(userId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(2); + result.Value.Items.Select(t => t.Title).Should().Equal("Alpha", "Beta"); + } + + [Fact] + public async Task Filters_by_normalized_name_contains() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + await SeedTrackForUserAsync(ctx, userId, "Summer Hit", DateTime.UtcNow); + await SeedTrackForUserAsync(ctx, userId, "Winter Song", DateTime.UtcNow); + + var sut = new GetTracksByUserIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTracksByUserIdQuery(userId, Name: "summer"), CancellationToken.None); + + result.Value.TotalItemCount.Should().Be(1); + result.Value.Items.Single().Title.Should().Be("Summer Hit"); + } + + [Fact] + public async Task Normalizes_invalid_paging_arguments_to_defaults() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + for (var i = 0; i < 3; i++) + await SeedTrackForUserAsync(ctx, userId, $"Track {i}", DateTime.UtcNow.AddSeconds(i)); + + var sut = new GetTracksByUserIdQueryHandler(ctx.Database); + var result = await sut.Handle( + new GetTracksByUserIdQuery(userId, PageNumber: 0, PageSize: -5), CancellationToken.None); + + result.Value.PageNumber.Should().Be(1); + result.Value.PageSize.Should().Be(10); + result.Value.TotalItemCount.Should().Be(3); + } + + [Fact] + public async Task Returns_empty_page_when_user_has_no_tracks() + { + await using var ctx = new SqliteTestContext(); + + var sut = new GetTracksByUserIdQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTracksByUserIdQuery(Guid.NewGuid()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(0); + result.Value.Items.Should().BeEmpty(); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/ListQueryHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/ListQueryHandlerTests.cs new file mode 100644 index 0000000..326a9fe --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/ListQueryHandlerTests.cs @@ -0,0 +1,135 @@ +using FluentAssertions; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.PlayLists.Queries; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Handlers; +using Musify.Application.Tracks.Queries; +using Musify.Application.Users.Handlers; +using Musify.Application.Users.Queries; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class ListQueryHandlerTests +{ + [Fact] + public async Task GetTracks_paginates_across_all_users() + { + await using var ctx = new SqliteTestContext(); + for (var i = 0; i < 15; i++) + { + var track = TestData.Track(title: $"Track {i:D2}"); + ctx.Database.Tracks.Add(track); + ctx.Database.UserHasTracks.Add(new UserHasTrack { UserId = Guid.NewGuid(), TrackId = track.Id }); + } + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetTracksQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTracksQuery(PageNumber: 2, PageSize: 10), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(15); + result.Value.PageNumber.Should().Be(2); + result.Value.Items.Should().HaveCount(5); + result.Value.HasPreviousPage.Should().BeTrue(); + result.Value.HasNextPage.Should().BeFalse(); + } + + [Fact] + public async Task GetTracks_normalizes_invalid_paging_to_defaults() + { + await using var ctx = new SqliteTestContext(); + var track = TestData.Track(); + ctx.Database.Tracks.Add(track); + ctx.Database.UserHasTracks.Add(new UserHasTrack { UserId = Guid.NewGuid(), TrackId = track.Id }); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetTracksQueryHandler(ctx.Database); + var result = await sut.Handle(new GetTracksQuery(PageNumber: 0, PageSize: -3), CancellationToken.None); + + result.Value.PageNumber.Should().Be(1); + result.Value.PageSize.Should().Be(10); + result.Value.TotalItemCount.Should().Be(1); + } + + [Fact] + public async Task GetPlayLists_returns_all_playlists_paginated() + { + await using var ctx = new SqliteTestContext(); + for (var i = 0; i < 3; i++) + ctx.Database.PlayLists.Add(TestData.PlayList(Guid.NewGuid(), name: $"PL {i}")); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetPlayListsQueryHandler(ctx.Database); + var result = await sut.Handle(new GetPlayListsQuery(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(3); + result.Value.Items.Should().HaveCount(3); + } + + [Fact] + public async Task GetPlayListsByUserId_filters_by_user_and_name() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + ctx.Database.PlayLists.Add(TestData.PlayList(userId, name: "Summer Mix")); + ctx.Database.PlayLists.Add(TestData.PlayList(userId, name: "Winter Mix")); + ctx.Database.PlayLists.Add(TestData.PlayList(Guid.NewGuid(), name: "Summer Other")); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetPlayListsByUserIdQueryHandler(ctx.Database); + + var all = await sut.Handle(new GetPlayListsByUserIdQuery(userId), CancellationToken.None); + all.Value.TotalItemCount.Should().Be(2); + + var filtered = await sut.Handle(new GetPlayListsByUserIdQuery(userId, Name: "summer"), CancellationToken.None); + filtered.Value.TotalItemCount.Should().Be(1); + filtered.Value.Items.Single().Name.Should().Be("Summer Mix"); + } + + [Fact] + public async Task GetPlayListsByUserId_returns_empty_for_unknown_user() + { + await using var ctx = new SqliteTestContext(); + var sut = new GetPlayListsByUserIdQueryHandler(ctx.Database); + + var result = await sut.Handle(new GetPlayListsByUserIdQuery(Guid.NewGuid()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(0); + } + + [Fact] + public async Task GetUsers_filters_by_username_search_case_insensitively() + { + await using var ctx = new SqliteTestContext(); + ctx.Database.Users.Add(TestData.User(name: "Alice")); + ctx.Database.Users.Add(TestData.User(name: "Bob")); + ctx.Database.Users.Add(TestData.User(name: "Alicia")); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetUsersQueryHandler(ctx.Database); + var result = await sut.Handle(new GetUsersQuery(1, 10, UsernameSearch: "ali"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalItemCount.Should().Be(2); + result.Value.Items.Select(u => u.Name).Should().BeEquivalentTo("Alice", "Alicia"); + } + + [Fact] + public async Task GetUsers_normalizes_invalid_paging_to_defaults() + { + await using var ctx = new SqliteTestContext(); + ctx.Database.Users.Add(TestData.User(name: "Solo")); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = new GetUsersQueryHandler(ctx.Database); + var result = await sut.Handle(new GetUsersQuery(0, -1), CancellationToken.None); + + result.Value.PageNumber.Should().Be(1); + result.Value.PageSize.Should().Be(10); + result.Value.TotalItemCount.Should().Be(1); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/RequestPlayListPictureUploadCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/RequestPlayListPictureUploadCommandHandlerTests.cs new file mode 100644 index 0000000..2268920 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -0,0 +1,129 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class RequestPlayListPictureUploadCommandHandlerTests +{ + private readonly IStorageService _storage = Substitute.For(); + + private RequestPlayListPictureUploadCommandHandler CreateSut(TestDatabase db, UploadIntentConfiguration? config = null) => + new( + db, + new UploadIntentValidator(db, _storage), + _storage, + NullLogger.Instance, + TestData.StorageConfig(), + TestData.PlayListConfig(), + config ?? TestData.UploadIntentConfig()); + + private static RequestPlayListPictureUploadCommand Command(Guid userId, long? size = null) => + new(userId, ".WEBP", "image/webp", size); + + [Fact] + public async Task When_user_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle(Command(Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task Happy_path_creates_single_intent_and_returns_url() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.UploadUrl.Should().Be("https://signed.example/upload"); + + var intents = await ctx.NewContext().UploadIntents.Where(i => i.UserId == user.Id).ToListAsync(); + intents.Should().ContainSingle(); + intents[0].Purpose.Should().Be(UploadIntentPurpose.PlayListPicture); + intents[0].ObjectName.Should().EndWith(".webp"); + } + + [Fact] + public async Task When_quota_exceeded_returns_invalid_and_creates_no_intent() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadIntentsPerUser = 0; + + var sut = CreateSut(ctx.Database, config); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + (await ctx.NewContext().UploadIntents.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Uses_default_picture_size_when_not_provided() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var config = TestData.UploadIntentConfig(); + var sut = CreateSut(ctx.Database, config); + await sut.Handle(Command(user.Id), CancellationToken.None); + + var intent = await ctx.NewContext().UploadIntents.SingleAsync(i => i.UserId == user.Id); + intent.ExpectedSizeBytes.Should().Be(config.DefaultExpectedPictureSizeBytes); + } + + [Fact] + public async Task When_storage_throws_returns_error() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("s3 down")); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Error); + (await ctx.NewContext().UploadIntents.CountAsync()).Should().Be(0); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/RequestTrackUploadUrlsCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/RequestTrackUploadUrlsCommandHandlerTests.cs new file mode 100644 index 0000000..c116dd4 --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -0,0 +1,134 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Handlers; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class RequestTrackUploadUrlsCommandHandlerTests +{ + private readonly IStorageService _storage = Substitute.For(); + + private RequestTrackUploadUrlsCommandHandler CreateSut(TestDatabase db, UploadIntentConfiguration? config = null) => + new( + db, + new UploadIntentValidator(db, _storage), + _storage, + NullLogger.Instance, + TestData.StorageConfig(), + TestData.TrackConfig(), + config ?? TestData.UploadIntentConfig()); + + private static RequestTrackUploadUrlsCommand Command(Guid userId, long? pic = null, long? audio = null) => + new(userId, ".PNG", "image/png", "mp3", "audio/mpeg", pic, audio); + + [Fact] + public async Task When_user_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle(Command(Guid.NewGuid()), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task Happy_path_creates_two_intents_and_returns_urls() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.PictureUploadUrl.Should().Be("https://signed.example/upload"); + + var intents = await ctx.NewContext().UploadIntents.Where(i => i.UserId == user.Id).ToListAsync(); + intents.Should().HaveCount(2); + intents.Select(i => i.Purpose).Should().BeEquivalentTo( + new[] { UploadIntentPurpose.TrackPicture, UploadIntentPurpose.TrackAudio }); + intents.Should().Contain(i => i.ObjectName.EndsWith(".png")); + intents.Should().Contain(i => i.ObjectName.EndsWith(".mp3")); + } + + [Fact] + public async Task When_quota_exceeded_returns_invalid_and_creates_no_intents() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadIntentsPerUser = 1; + + var sut = CreateSut(ctx.Database, config); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + (await ctx.NewContext().UploadIntents.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Uses_default_sizes_when_not_provided() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/upload"); + + var config = TestData.UploadIntentConfig(); + var sut = CreateSut(ctx.Database, config); + await sut.Handle(Command(user.Id), CancellationToken.None); + + var intents = await ctx.NewContext().UploadIntents.Where(i => i.UserId == user.Id).ToListAsync(); + intents.Single(i => i.Purpose == UploadIntentPurpose.TrackPicture) + .ExpectedSizeBytes.Should().Be(config.DefaultExpectedPictureSizeBytes); + intents.Single(i => i.Purpose == UploadIntentPurpose.TrackAudio) + .ExpectedSizeBytes.Should().Be(config.DefaultExpectedAudioSizeBytes); + } + + [Fact] + public async Task When_storage_throws_returns_error() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.GetUploadUrlAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("s3 down")); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle(Command(user.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Error); + (await ctx.NewContext().UploadIntents.CountAsync()).Should().Be(0); + } +} diff --git a/Tests/Musify.Application.Tests/Handlers/UpdatePlayListCommandHandlerTests.cs b/Tests/Musify.Application.Tests/Handlers/UpdatePlayListCommandHandlerTests.cs new file mode 100644 index 0000000..52957bc --- /dev/null +++ b/Tests/Musify.Application.Tests/Handlers/UpdatePlayListCommandHandlerTests.cs @@ -0,0 +1,144 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Handlers; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Handlers; + +public sealed class UpdatePlayListCommandHandlerTests +{ + private readonly IEventBus _eventBus = Substitute.For(); + private readonly IStorageService _storage = Substitute.For(); + + private UpdatePlayListCommandHandler CreateSut(TestDatabase db) => + new( + _eventBus, + db, + new UploadIntentValidator(db, _storage), + NullLogger.Instance, + TestData.StorageConfig(), + TestData.PlayListConfig(), + TestData.UploadIntentConfig()); + + [Fact] + public async Task When_playlist_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.Handle( + new UpdatePlayListCommand(Guid.NewGuid(), Guid.NewGuid(), "n", null, null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task When_user_not_owner_returns_unauthorized() + { + await using var ctx = new SqliteTestContext(); + var playList = TestData.PlayList(Guid.NewGuid()); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new UpdatePlayListCommand(Guid.NewGuid(), playList.Id, "n", null, null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Unauthorized); + } + + [Fact] + public async Task Updates_name_and_description_without_publishing() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId, name: "Old"); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new UpdatePlayListCommand(userId, playList.Id, " New Name ", "new desc", null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Ok); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.Id == playList.Id); + persisted.Name.Should().Be(" New Name "); + persisted.NormalizedName.Should().Be("NEW NAME"); + persisted.Description.Should().Be("new desc"); + } + + [Fact] + public async Task When_only_description_provided_name_is_unchanged() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId, name: "Keep"); + ctx.Database.PlayLists.Add(playList); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new UpdatePlayListCommand(userId, playList.Id, null, "only desc", null), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Ok); + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.Id == playList.Id); + persisted.Name.Should().Be("Keep"); + persisted.Description.Should().Be("only desc"); + } + + [Fact] + public async Task With_valid_new_picture_intent_publishes_source_event_and_updates_picture_name() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId); + var intent = TestData.UploadIntent(userId, UploadIntentPurpose.PlayListPicture, objectName: "newcover.webp"); + ctx.Database.PlayLists.Add(playList); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.HeadObjectAsync(intent.Bucket, intent.Key, Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 500)); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new UpdatePlayListCommand(userId, playList.Id, null, null, intent.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Ok); + await _eventBus.Received(1).PublishAsync( + Arg.Is(e => e.PlayListId == playList.Id && e.PictureIntentId == intent.Id), + Arg.Any()); + + var persisted = await ctx.NewContext().PlayLists.SingleAsync(p => p.Id == playList.Id); + persisted.OriginalPictureName.Should().Be("newcover.webp"); + } + + [Fact] + public async Task With_invalid_new_picture_intent_propagates_error() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var playList = TestData.PlayList(userId); + var intent = TestData.UploadIntent(userId, UploadIntentPurpose.PlayListPicture, status: UploadIntentStatus.Consumed); + ctx.Database.PlayLists.Add(playList); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.Handle( + new UpdatePlayListCommand(userId, playList.Id, null, null, intent.Id), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Conflict); + } +} diff --git a/Tests/Musify.Application.Tests/Infrastructure/SchemaSmokeTests.cs b/Tests/Musify.Application.Tests/Infrastructure/SchemaSmokeTests.cs new file mode 100644 index 0000000..5544404 --- /dev/null +++ b/Tests/Musify.Application.Tests/Infrastructure/SchemaSmokeTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Musify.Application.Tests.Infrastructure; + +public sealed class SchemaSmokeTests +{ + [Fact] + public async Task Schema_creates_and_persists_all_core_entities() + { + await using var ctx = new SqliteTestContext(); + + var user = TestData.User(); + ctx.Database.Users.Add(user); + ctx.Database.UploadIntents.Add(TestData.UploadIntent(user.Id)); + ctx.Database.PlayLists.Add(TestData.PlayList(user.Id)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var verify = ctx.NewContext(); + (await verify.Users.CountAsync()).Should().Be(1); + (await verify.UploadIntents.CountAsync()).Should().Be(1); + (await verify.PlayLists.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Transactions_are_supported_under_sqlite() + { + await using var ctx = new SqliteTestContext(); + var user = TestData.User(); + + await using (var tx = await ctx.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, CancellationToken.None)) + { + ctx.Database.Users.Add(user); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + await tx.CommitAsync(CancellationToken.None); + } + + (await ctx.NewContext().Users.CountAsync()).Should().Be(1); + } +} diff --git a/Tests/Musify.Application.Tests/Infrastructure/SqliteTestContext.cs b/Tests/Musify.Application.Tests/Infrastructure/SqliteTestContext.cs new file mode 100644 index 0000000..ced7b5b --- /dev/null +++ b/Tests/Musify.Application.Tests/Infrastructure/SqliteTestContext.cs @@ -0,0 +1,44 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Musify.Application.Tests.Infrastructure; + +public sealed class SqliteTestContext : IAsyncDisposable, IDisposable +{ + private readonly SqliteConnection _connection; + + public TestDatabase Database { get; } + + public SqliteTestContext() + { + _connection = new SqliteConnection("DataSource=:memory:;Foreign Keys=False"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + Database = new TestDatabase(options); + Database.Database.EnsureCreated(); + } + + public TestDatabase NewContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + return new TestDatabase(options); + } + + public void Dispose() + { + Database.Dispose(); + _connection.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await Database.DisposeAsync(); + await _connection.DisposeAsync(); + } +} diff --git a/Tests/Musify.Application.Tests/Infrastructure/TestData.cs b/Tests/Musify.Application.Tests/Infrastructure/TestData.cs new file mode 100644 index 0000000..2e8ef62 --- /dev/null +++ b/Tests/Musify.Application.Tests/Infrastructure/TestData.cs @@ -0,0 +1,75 @@ +using Musify.Application.Configuration; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tests.Infrastructure; + +public static class TestData +{ + public const string Bucket = "musify-test"; + + public static ApplicationStorageConfiguration StorageConfig() => new() { Bucket = Bucket }; + + public static UploadIntentConfiguration UploadIntentConfig() => new(); + + public static TrackConfiguration TrackConfig() => new(); + + public static PlayListConfiguration PlayListConfig() => new(); + + public static User User(Guid? id = null, string name = "Alice") => new() + { + Id = id ?? Guid.NewGuid(), + Name = name, + NormalizedName = name.ToUpperInvariant(), + }; + + public static UploadIntent UploadIntent( + Guid userId, + UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, + UploadIntentStatus status = UploadIntentStatus.Issued, + DateTime? expiresAt = null, + long? expectedSizeBytes = null, + string objectName = "object.webp", + string? key = null) => new() + { + UserId = userId, + Bucket = Bucket, + Key = key ?? $"temp/{userId}/Tracks/{objectName}", + ObjectName = objectName, + ContentType = "image/webp", + ExpectedSizeBytes = expectedSizeBytes, + Purpose = purpose, + Status = status, + ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5), + }; + + public static Track Track( + string title = "Song", + ProcessingStatus audioStatus = ProcessingStatus.Completed, + LifeCycleStatus lifeCycle = LifeCycleStatus.Active) => new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OriginalPictureName = "pic.webp", + OriginalAudioName = "audio.mp3", + AudioTranscodeProcessingStatus = audioStatus, + PicturesProcessingStatus = ProcessingStatus.Completed, + LifeCycleStatus = lifeCycle, + }; + + public static PlayList PlayList( + Guid userId, + string name = "My PlayList", + LifeCycleStatus lifeCycle = LifeCycleStatus.Active) => new() + { + UserId = userId, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Description = "desc", + OriginalPictureName = "orig.webp", + SmallPictureName = "small.webp", + MediumPictureName = "medium.webp", + LargePictureName = "large.webp", + LifeCycleStatus = lifeCycle, + }; +} diff --git a/Tests/Musify.Application.Tests/Infrastructure/TestDatabase.cs b/Tests/Musify.Application.Tests/Infrastructure/TestDatabase.cs new file mode 100644 index 0000000..0d70d13 --- /dev/null +++ b/Tests/Musify.Application.Tests/Infrastructure/TestDatabase.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Musify.Domain.Entities; +using AppIDatabase = Musify.Application.Contracts.IDatabase; +using IDatabaseTransaction = Musify.Application.Contracts.IDatabaseTransaction; + +namespace Musify.Application.Tests.Infrastructure; + +public sealed class TestDatabase(DbContextOptions options) : DbContext(options), AppIDatabase +{ + public DbSet Users => Set(); + + public DbSet Tracks => Set(); + + public DbSet PlayLists => Set(); + + public DbSet UserHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public async Task BeginTransactionAsync( + System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new TestDatabaseTransaction(transaction); + } + + private sealed class TestDatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } +} diff --git a/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj b/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj new file mode 100644 index 0000000..67ef5a4 --- /dev/null +++ b/Tests/Musify.Application.Tests/Musify.Application.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + Musify.Application.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/Tests/Musify.Application.Tests/Pagination/PaginatedResponseTests.cs b/Tests/Musify.Application.Tests/Pagination/PaginatedResponseTests.cs new file mode 100644 index 0000000..4cfc561 --- /dev/null +++ b/Tests/Musify.Application.Tests/Pagination/PaginatedResponseTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using Musify.Application.Pagination; +using X.PagedList; +using Xunit; + +namespace Musify.Application.Tests.Pagination; + +public sealed class PaginatedResponseTests +{ + [Fact] + public void FromPagedList_maps_all_fields_for_a_middle_page() + { + var source = Enumerable.Range(1, 25).ToList(); + var paged = new PagedList(source, pageNumber: 2, pageSize: 10); + + var response = PaginatedResponse.FromPagedList(paged); + + response.PageNumber.Should().Be(2); + response.PageSize.Should().Be(10); + response.PageCount.Should().Be(3); + response.TotalItemCount.Should().Be(25); + response.HasNextPage.Should().BeTrue(); + response.HasPreviousPage.Should().BeTrue(); + response.Items.Should().Equal(11, 12, 13, 14, 15, 16, 17, 18, 19, 20); + } + + [Fact] + public void FromPagedList_first_page_has_no_previous_page() + { + var paged = new PagedList(Enumerable.Range(1, 5).ToList(), pageNumber: 1, pageSize: 10); + + var response = PaginatedResponse.FromPagedList(paged); + + response.HasPreviousPage.Should().BeFalse(); + response.HasNextPage.Should().BeFalse(); + response.TotalItemCount.Should().Be(5); + } +} diff --git a/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs new file mode 100644 index 0000000..791d888 --- /dev/null +++ b/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -0,0 +1,256 @@ +using Ardalis.Result; +using FluentAssertions; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Services; + +public sealed class UploadIntentValidatorTests +{ + private readonly IStorageService _storage = Substitute.For(); + + private UploadIntentValidator CreateSut(TestDatabase database) => new(database, _storage); + + [Fact] + public async Task CheckQuota_under_limits_returns_success() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + var sut = CreateSut(ctx.Database); + + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 1_000, requiredIntentCount: 1, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CheckQuota_when_intent_count_would_exceed_max_returns_invalid() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadIntentsPerUser = 2; + + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, expectedSizeBytes: 1)); + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, expectedSizeBytes: 1)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 1, requiredIntentCount: 1, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task CheckQuota_exactly_at_intent_limit_is_allowed() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadIntentsPerUser = 2; + + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, expectedSizeBytes: 1)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 1, requiredIntentCount: 1, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CheckQuota_when_byte_quota_exceeded_returns_invalid() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadBytesPerUser = 1_000; + + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, expectedSizeBytes: 800)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 300, requiredIntentCount: 1, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task CheckQuota_uses_default_picture_size_for_intents_without_expected_size() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadBytesPerUser = 1_000; + config.DefaultExpectedPictureSizeBytes = 900; + + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, expectedSizeBytes: null)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 200, requiredIntentCount: 1, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task CheckQuota_ignores_other_users_and_non_issued_intents() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var otherUser = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxActiveUploadIntentsPerUser = 2; + config.MaxActiveUploadBytesPerUser = 1_000; + + ctx.Database.UploadIntents.Add(TestData.UploadIntent(otherUser, expectedSizeBytes: 5_000)); + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, status: UploadIntentStatus.Consumed, expectedSizeBytes: 5_000)); + ctx.Database.UploadIntents.Add(TestData.UploadIntent(userId, status: UploadIntentStatus.Expired, expectedSizeBytes: 5_000)); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.CheckQuotaAsync(config, userId, requiredBytes: 100, requiredIntentCount: 1, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAndLoad_when_intent_missing_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var sut = CreateSut(ctx.Database); + + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), Guid.NewGuid(), Guid.NewGuid(), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task ValidateAndLoad_when_intent_belongs_to_other_user_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var ownerId = Guid.NewGuid(); + var intent = TestData.UploadIntent(ownerId); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, Guid.NewGuid(), CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task ValidateAndLoad_when_consumed_returns_conflict() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var intent = TestData.UploadIntent(userId, status: UploadIntentStatus.Consumed); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, userId, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Conflict); + } + + [Fact] + public async Task ValidateAndLoad_when_status_expired_returns_invalid() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var intent = TestData.UploadIntent(userId, status: UploadIntentStatus.Expired); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, userId, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task ValidateAndLoad_when_expired_by_timestamp_returns_invalid() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var intent = TestData.UploadIntent(userId, status: UploadIntentStatus.Issued, expiresAt: DateTime.UtcNow.AddMinutes(-1)); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, userId, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task ValidateAndLoad_when_object_missing_in_storage_returns_not_found() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var intent = TestData.UploadIntent(userId); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.HeadObjectAsync(intent.Bucket, intent.Key, Arg.Any()) + .Returns((ObjectMetaData?)null); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, userId, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.NotFound); + } + + [Fact] + public async Task ValidateAndLoad_when_object_exceeds_max_size_returns_invalid() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var config = TestData.UploadIntentConfig(); + config.MaxUploadBytes = 1_000; + var intent = TestData.UploadIntent(userId); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.HeadObjectAsync(intent.Bucket, intent.Key, Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2_000)); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync(config, intent.Id, userId, CancellationToken.None); + + result.Status.Should().Be(ResultStatus.Invalid); + } + + [Fact] + public async Task ValidateAndLoad_happy_path_returns_loaded_intent() + { + await using var ctx = new SqliteTestContext(); + var userId = Guid.NewGuid(); + var intent = TestData.UploadIntent(userId); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + _storage.HeadObjectAsync(intent.Bucket, intent.Key, Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 500)); + + var sut = CreateSut(ctx.Database); + var result = await sut.ValidateAndLoadAsync( + TestData.UploadIntentConfig(), intent.Id, userId, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(intent.Id); + } +} diff --git a/Tests/Musify.Infrastructure.Tests/JobTestHarness.cs b/Tests/Musify.Infrastructure.Tests/JobTestHarness.cs new file mode 100644 index 0000000..2e5fe4a --- /dev/null +++ b/Tests/Musify.Infrastructure.Tests/JobTestHarness.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Contracts; +using Musify.Application.Tests.Infrastructure; + +namespace Musify.Infrastructure.Tests; + +internal static class JobTestHarness +{ + public static IServiceScopeFactory ScopeFactoryWith(Action configure) + { + var services = new ServiceCollection(); + configure(services); + return services.BuildServiceProvider().GetRequiredService(); + } + + public static IServiceScopeFactory WithDatabase(IDatabase database) => + ScopeFactoryWith(s => s.AddSingleton(database)); + + public static IServiceScopeFactory WithStorage(IStorageService storage) => + ScopeFactoryWith(s => s.AddSingleton(storage)); + + public static Task RunAsync(object job, CancellationToken cancellationToken = default) + { + var method = job.GetType().GetMethod( + "RunAsync", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new InvalidOperationException("RunAsync not found on job."); + + return (Task)method.Invoke(job, [cancellationToken])!; + } +} diff --git a/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj b/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj new file mode 100644 index 0000000..820228f --- /dev/null +++ b/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + Musify.Infrastructure.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/Tests/Musify.Infrastructure.Tests/TemporalUploadsCleanUpJobTests.cs b/Tests/Musify.Infrastructure.Tests/TemporalUploadsCleanUpJobTests.cs new file mode 100644 index 0000000..2b255d3 --- /dev/null +++ b/Tests/Musify.Infrastructure.Tests/TemporalUploadsCleanUpJobTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Contracts; +using Musify.Application.Tests.Infrastructure; +using Musify.Infrastructure.Jobs; +using NSubstitute; +using Xunit; + +namespace Musify.Infrastructure.Tests; + +public sealed class TemporalUploadsCleanUpJobTests +{ + private readonly IStorageService _storage = Substitute.For(); + private readonly Application.Configuration.UploadIntentConfiguration _config = TestData.UploadIntentConfig(); + + private TemporalUploadsCleanUpJob CreateJob() => + new( + JobTestHarness.WithStorage(_storage), + NullLogger.Instance, + TestData.StorageConfig(), + _config); + + private static async IAsyncEnumerable AsAsync(params StorageObject[] items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + + [Fact] + public async Task Deletes_only_objects_older_than_retention_window() + { + var cutoffDays = _config.TempUploadsRetentionDays; + var stale = new StorageObject("temp/u1/old.webp", DateTime.UtcNow.AddDays(-(cutoffDays + 2))); + var fresh = new StorageObject("temp/u1/new.webp", DateTime.UtcNow.AddHours(-1)); + + _storage.ListObjectsAsync(TestData.Bucket, _config.TempRootPrefix, Arg.Any()) + .Returns(AsAsync(stale, fresh)); + + await JobTestHarness.RunAsync(CreateJob()); + + await _storage.Received(1).RemoveFileAsync(TestData.Bucket, stale.Key, Arg.Any()); + await _storage.DidNotReceive().RemoveFileAsync(TestData.Bucket, fresh.Key, Arg.Any()); + } + + [Fact] + public async Task Continues_when_a_delete_fails() + { + var first = new StorageObject("temp/u1/a.webp", DateTime.UtcNow.AddDays(-10)); + var second = new StorageObject("temp/u1/b.webp", DateTime.UtcNow.AddDays(-10)); + + _storage.ListObjectsAsync(TestData.Bucket, _config.TempRootPrefix, Arg.Any()) + .Returns(AsAsync(first, second)); + _storage.RemoveFileAsync(TestData.Bucket, first.Key, Arg.Any()) + .Returns(_ => throw new InvalidOperationException("s3 down")); + + await JobTestHarness.RunAsync(CreateJob()); + + await _storage.Received(1).RemoveFileAsync(TestData.Bucket, second.Key, Arg.Any()); + } + + [Fact] + public async Task Does_nothing_when_no_objects_listed() + { + _storage.ListObjectsAsync(TestData.Bucket, _config.TempRootPrefix, Arg.Any()) + .Returns(AsAsync()); + + await JobTestHarness.RunAsync(CreateJob()); + + await _storage.DidNotReceive().RemoveFileAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/Tests/Musify.Infrastructure.Tests/UploadIntentExpirationJobTests.cs b/Tests/Musify.Infrastructure.Tests/UploadIntentExpirationJobTests.cs new file mode 100644 index 0000000..cca62fb --- /dev/null +++ b/Tests/Musify.Infrastructure.Tests/UploadIntentExpirationJobTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Application.Tests.Infrastructure; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Jobs; +using Xunit; + +namespace Musify.Infrastructure.Tests; + +public sealed class UploadIntentExpirationJobTests +{ + private static UploadIntentExpirationJob CreateJob(SqliteTestContext ctx, Action? configure = null) + { + var config = TestData.UploadIntentConfig(); + configure?.Invoke(config); + return new UploadIntentExpirationJob( + JobTestHarness.WithDatabase(ctx.Database), + NullLogger.Instance, + config); + } + + [Fact] + public async Task Marks_issued_intents_past_expiry_as_expired() + { + await using var ctx = new SqliteTestContext(); + var expired = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Issued, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Issued, expiresAt: DateTime.UtcNow.AddMinutes(30)); + ctx.Database.UploadIntents.AddRange(expired, active); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + await JobTestHarness.RunAsync(CreateJob(ctx)); + + var verify = ctx.NewContext(); + (await verify.UploadIntents.SingleAsync(i => i.Id == expired.Id)).Status.Should().Be(UploadIntentStatus.Expired); + (await verify.UploadIntents.SingleAsync(i => i.Id == active.Id)).Status.Should().Be(UploadIntentStatus.Issued); + } + + [Fact] + public async Task Deletes_expired_intents_past_retention_window() + { + await using var ctx = new SqliteTestContext(); + var config = TestData.UploadIntentConfig(); + var old = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Expired, + expiresAt: DateTime.UtcNow.AddDays(-(config.ExpiredIntentsRetentionDays + 1))); + var recent = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Expired, + expiresAt: DateTime.UtcNow.AddHours(-1)); + ctx.Database.UploadIntents.AddRange(old, recent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + await JobTestHarness.RunAsync(CreateJob(ctx)); + + var verify = ctx.NewContext(); + (await verify.UploadIntents.AnyAsync(i => i.Id == old.Id)).Should().BeFalse(); + (await verify.UploadIntents.AnyAsync(i => i.Id == recent.Id)).Should().BeTrue(); + } + + [Fact] + public async Task With_zero_retention_just_expired_intents_are_marked_then_deleted() + { + await using var ctx = new SqliteTestContext(); + var intent = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Issued, expiresAt: DateTime.UtcNow.AddMinutes(-1)); + ctx.Database.UploadIntents.Add(intent); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + await JobTestHarness.RunAsync(CreateJob(ctx, c => c.ExpiredIntentsRetentionDays = 0)); + + (await ctx.NewContext().UploadIntents.AnyAsync(i => i.Id == intent.Id)).Should().BeFalse(); + } + + [Fact] + public async Task Leaves_consumed_intents_untouched() + { + await using var ctx = new SqliteTestContext(); + var consumed = TestData.UploadIntent(Guid.NewGuid(), status: UploadIntentStatus.Consumed, expiresAt: DateTime.UtcNow.AddDays(-10)); + ctx.Database.UploadIntents.Add(consumed); + await ctx.Database.SaveChangesAsync(CancellationToken.None); + + await JobTestHarness.RunAsync(CreateJob(ctx)); + + (await ctx.NewContext().UploadIntents.SingleAsync(i => i.Id == consumed.Id)).Status + .Should().Be(UploadIntentStatus.Consumed); + } +}