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
@@ -14,10 +14,13 @@ namespace Musify.Application.PlayLists.Handlers
{ {
var playList = await database.PlayLists var playList = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.Where(p => p.Id == request.Id)
.Select(p => PlayListApplicationResponse.FromEntity(p)) .Select(p => PlayListApplicationResponse.FromEntity(p))
.SingleOrDefaultAsync(p => p.Id == request.Id, cancellationToken); .SingleOrDefaultAsync(cancellationToken);
return playList ?? Result<PlayListApplicationResponse>.NotFound(); return playList is null
? Result<PlayListApplicationResponse>.NotFound()
: Result.Success(playList);
} }
} }
} }
@@ -13,10 +13,13 @@ namespace Musify.Application.Tracks.Handlers
public async ValueTask<Result<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) public async ValueTask<Result<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{ {
var track = await database.Tracks.AsNoTracking() var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => TrackApplicationResponse.FromEntity(t)) .Select(t => TrackApplicationResponse.FromEntity(t))
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); .SingleOrDefaultAsync(cancellationToken);
return track ?? Result<TrackApplicationResponse>.NotFound(); return track is null
? Result<TrackApplicationResponse>.NotFound()
: Result.Success(track);
} }
} }
} }
@@ -14,10 +14,13 @@ namespace Musify.Application.Users.Handlers
{ {
var user = await database.Users var user = await database.Users
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == request.Id)
.Select(u => UserApplicationResponse.FromEntity(u)) .Select(u => UserApplicationResponse.FromEntity(u))
.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); .SingleOrDefaultAsync(cancellationToken);
return user ?? Result<UserApplicationResponse>.NotFound(); return user is null
? Result<UserApplicationResponse>.NotFound()
: Result.Success(user);
} }
} }
} }
+4
View File
@@ -1,4 +1,8 @@
<Solution> <Solution>
<Folder Name="/Tests/">
<Project Path="Tests/Musify.Application.Tests/Musify.Application.Tests.csproj" />
<Project Path="Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj" />
</Folder>
<Project Path="Application/Application.csproj" /> <Project Path="Application/Application.csproj" />
<Project Path="Infrastructure/Infrastructure.csproj" /> <Project Path="Infrastructure/Infrastructure.csproj" />
<Project Path="Domain/Domain.csproj" /> <Project Path="Domain/Domain.csproj" />
@@ -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");
}
}
@@ -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);
}
}
@@ -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<int>.NotFound("missing");
var mapped = source.As<int, string>();
mapped.Status.Should().Be(ResultStatus.NotFound);
mapped.Errors.Should().Contain("missing");
}
[Fact]
public void Conflict_maps_to_Conflict()
{
Result<int>.Conflict("dup").As<int, string>().Status.Should().Be(ResultStatus.Conflict);
}
[Fact]
public void Unauthorized_maps_to_Unauthorized()
{
Result<int>.Unauthorized().As<int, string>().Status.Should().Be(ResultStatus.Unauthorized);
}
[Fact]
public void Forbidden_maps_to_Forbidden()
{
Result<int>.Forbidden().As<int, string>().Status.Should().Be(ResultStatus.Forbidden);
}
[Fact]
public void Invalid_maps_to_Invalid_and_preserves_validation_errors()
{
var source = Result<int>.Invalid(new ValidationError("field", "bad"));
var mapped = source.As<int, string>();
mapped.Status.Should().Be(ResultStatus.Invalid);
mapped.ValidationErrors.Should().ContainSingle(e => e.ErrorMessage == "bad");
}
[Fact]
public void Error_maps_to_Error()
{
Result<int>.Error("boom").As<int, string>().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<int> source = status switch
{
ResultStatus.Ok => Result<int>.Success(1),
ResultStatus.Created => Result<int>.Created(1),
ResultStatus.NoContent => Result<int>.NoContent(),
_ => throw new InvalidOperationException()
};
var act = () => source.As<int, string>();
act.Should().Throw<InvalidOperationException>();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<TestDatabase>()
.UseSqlite(_connection)
.Options;
Database = new TestDatabase(options);
Database.Database.EnsureCreated();
}
public TestDatabase NewContext()
{
var options = new DbContextOptionsBuilder<TestDatabase>()
.UseSqlite(_connection)
.Options;
return new TestDatabase(options);
}
public void Dispose()
{
Database.Dispose();
_connection.Dispose();
}
public async ValueTask DisposeAsync()
{
await Database.DisposeAsync();
await _connection.DisposeAsync();
}
}
@@ -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,
};
}
@@ -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<TestDatabase> options) : DbContext(options), AppIDatabase
{
public DbSet<User> Users => Set<User>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public async Task<IDatabaseTransaction> 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();
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Musify.Application.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\Application.csproj" />
<ProjectReference Include="..\..\Domain\Domain.csproj" />
</ItemGroup>
</Project>
@@ -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<int>(source, pageNumber: 2, pageSize: 10);
var response = PaginatedResponse<int>.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<int>(Enumerable.Range(1, 5).ToList(), pageNumber: 1, pageSize: 10);
var response = PaginatedResponse<int>.FromPagedList(paged);
response.HasPreviousPage.Should().BeFalse();
response.HasNextPage.Should().BeFalse();
response.TotalItemCount.Should().Be(5);
}
}
@@ -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<IStorageService>();
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<CancellationToken>())
.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<CancellationToken>())
.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<CancellationToken>())
.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);
}
}
@@ -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<IServiceCollection> configure)
{
var services = new ServiceCollection();
configure(services);
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
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])!;
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Musify.Infrastructure.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.csproj" />
<ProjectReference Include="..\Musify.Application.Tests\Musify.Application.Tests.csproj" />
</ItemGroup>
</Project>
@@ -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<IStorageService>();
private readonly Application.Configuration.UploadIntentConfiguration _config = TestData.UploadIntentConfig();
private TemporalUploadsCleanUpJob CreateJob() =>
new(
JobTestHarness.WithStorage(_storage),
NullLogger<TemporalUploadsCleanUpJob>.Instance,
TestData.StorageConfig(),
_config);
private static async IAsyncEnumerable<StorageObject> 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<CancellationToken>())
.Returns(AsAsync(stale, fresh));
await JobTestHarness.RunAsync(CreateJob());
await _storage.Received(1).RemoveFileAsync(TestData.Bucket, stale.Key, Arg.Any<CancellationToken>());
await _storage.DidNotReceive().RemoveFileAsync(TestData.Bucket, fresh.Key, Arg.Any<CancellationToken>());
}
[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<CancellationToken>())
.Returns(AsAsync(first, second));
_storage.RemoveFileAsync(TestData.Bucket, first.Key, Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("s3 down"));
await JobTestHarness.RunAsync(CreateJob());
await _storage.Received(1).RemoveFileAsync(TestData.Bucket, second.Key, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Does_nothing_when_no_objects_listed()
{
_storage.ListObjectsAsync(TestData.Bucket, _config.TempRootPrefix, Arg.Any<CancellationToken>())
.Returns(AsAsync());
await JobTestHarness.RunAsync(CreateJob());
await _storage.DidNotReceive().RemoveFileAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
@@ -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<Application.Configuration.UploadIntentConfiguration>? configure = null)
{
var config = TestData.UploadIntentConfig();
configure?.Invoke(config);
return new UploadIntentExpirationJob(
JobTestHarness.WithDatabase(ctx.Database),
NullLogger<UploadIntentExpirationJob>.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);
}
}