Added tests

This commit is contained in:
2026-05-29 13:49:44 +02:00
parent 0d5a6df27b
commit 51dd877a44
29 changed files with 2212 additions and 6 deletions
@@ -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<IEventBus>();
private readonly IStorageService _storage = Substitute.For<IStorageService>();
private CreatePlayListCommandHandler CreateSut(TestDatabase db) =>
new(
_eventBus,
db,
new UploadIntentValidator(db, _storage),
NullLogger<CreatePlayListCommandHandler>.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<CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
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<CancellationToken>())
.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<CreatePlayListResourcesEvent>(e => e.PictureIntentId == intent.Id),
Arg.Any<CancellationToken>());
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);
}
}
@@ -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<IEventBus>();
private readonly IStorageService _storage = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateSut(TestDatabase db) =>
new(
db,
_eventBus,
new UploadIntentValidator(db, _storage),
NullLogger<CreateTrackCommandHandler>.Instance,
TestData.StorageConfig(),
TestData.TrackConfig(),
TestData.UploadIntentConfig());
private void StorageHasObject() =>
_storage.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
}
[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<CreateTrackResourcesEvent>(e =>
e.PictureIntentId == picture.Id && e.AudioIntentId == audio.Id),
Arg.Any<CancellationToken>());
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<CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => 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);
}
}
@@ -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<CreateUserCommandHandler>.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<CreateUserCommandHandler>.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");
}
}
@@ -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<IEventBus>();
private DeletePlayListCommandHandler CreateSut(TestDatabase db) =>
new(db, _eventBus, NullLogger<DeletePlayListCommandHandler>.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<DeletePlayListEvent>(e => e.PlayListId == playList.Id && e.UserId == userId),
Arg.Any<CancellationToken>());
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<DeletePlayListEvent>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => 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);
}
}
@@ -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<IEventBus>();
private DeleteTrackCommandHandler CreateSut(TestDatabase db) =>
new(db, _eventBus, NullLogger<DeleteTrackCommandHandler>.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<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[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<DeleteTrackEvent>(e => e.TrackId == trackId), Arg.Any<CancellationToken>());
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<DeleteTrackEvent>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => 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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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<IStorageService>();
private RequestPlayListPictureUploadCommandHandler CreateSut(TestDatabase db, UploadIntentConfiguration? config = null) =>
new(
db,
new UploadIntentValidator(db, _storage),
_storage,
NullLogger<RequestPlayListPictureUploadCommandHandler>.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns<string>(_ => 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);
}
}
@@ -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<IStorageService>();
private RequestTrackUploadUrlsCommandHandler CreateSut(TestDatabase db, UploadIntentConfiguration? config = null) =>
new(
db,
new UploadIntentValidator(db, _storage),
_storage,
NullLogger<RequestTrackUploadUrlsCommandHandler>.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns<string>(_ => 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);
}
}
@@ -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<IEventBus>();
private readonly IStorageService _storage = Substitute.For<IStorageService>();
private UpdatePlayListCommandHandler CreateSut(TestDatabase db) =>
new(
_eventBus,
db,
new UploadIntentValidator(db, _storage),
NullLogger<UpdatePlayListCommandHandler>.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<UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
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<CancellationToken>())
.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<UpdatePlayListPictureSourceEvent>(e => e.PlayListId == playList.Id && e.PictureIntentId == intent.Id),
Arg.Any<CancellationToken>());
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);
}
}