Remove tests
This commit is contained in:
@@ -1,86 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<RootNamespace>Musify.Infrastructure.IntegrationTests</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="Testcontainers" Version="4.1.0" />
|
|
||||||
<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>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
using Amazon.S3;
|
|
||||||
using Amazon.S3.Model;
|
|
||||||
using DotNet.Testcontainers.Builders;
|
|
||||||
using DotNet.Testcontainers.Containers;
|
|
||||||
using Musify.Infrastructure.Configuration;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.IntegrationTests;
|
|
||||||
|
|
||||||
public sealed class SeaweedFsFixture : IAsyncLifetime
|
|
||||||
{
|
|
||||||
private const int S3Port = 8333;
|
|
||||||
private const string AccessKey = "admin_access_key";
|
|
||||||
private const string SecretKey = "admin_secret_key";
|
|
||||||
|
|
||||||
public const string Bucket = "webapi-storage";
|
|
||||||
|
|
||||||
private readonly IContainer _container = new ContainerBuilder()
|
|
||||||
.WithImage("chrislusf/seaweedfs:latest")
|
|
||||||
.WithCommand("server", "-s3", "-dir=/data", "-ip.bind=0.0.0.0")
|
|
||||||
.WithPortBinding(S3Port, true)
|
|
||||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Start Seaweed S3 API Server"))
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
public string ServiceUrl { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
public IAmazonS3 CreateClient()
|
|
||||||
{
|
|
||||||
var config = new AmazonS3Config
|
|
||||||
{
|
|
||||||
ServiceURL = ServiceUrl,
|
|
||||||
ForcePathStyle = true,
|
|
||||||
UseHttp = true,
|
|
||||||
AuthenticationRegion = "us-east-1",
|
|
||||||
};
|
|
||||||
return new AmazonS3Client(AccessKey, SecretKey, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public InfrastructureStorageConfiguration StorageConfig() => new()
|
|
||||||
{
|
|
||||||
Address = ServiceUrl,
|
|
||||||
AccessKey = AccessKey,
|
|
||||||
SecretAccessKey = SecretKey,
|
|
||||||
ForcePathStyle = true,
|
|
||||||
UseHttp = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
await _container.StartAsync();
|
|
||||||
ServiceUrl = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(S3Port)}";
|
|
||||||
|
|
||||||
using var client = CreateClient();
|
|
||||||
await client.PutBucketAsync(new PutBucketRequest { BucketName = Bucket });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DisposeAsync()
|
|
||||||
{
|
|
||||||
await _container.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[CollectionDefinition(SeaweedFsCollection.Name)]
|
|
||||||
public sealed class SeaweedFsCollection : ICollectionFixture<SeaweedFsFixture>
|
|
||||||
{
|
|
||||||
public const string Name = "SeaweedFS";
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tests.Infrastructure;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Infrastructure.Services;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.IntegrationTests;
|
|
||||||
|
|
||||||
[Collection(SeaweedFsCollection.Name)]
|
|
||||||
public sealed class StorageServiceTests(SeaweedFsFixture seaweedFs)
|
|
||||||
{
|
|
||||||
private const string Bucket = SeaweedFsFixture.Bucket;
|
|
||||||
|
|
||||||
private (StorageService service, SqliteTestContext db) CreateService()
|
|
||||||
{
|
|
||||||
var db = new SqliteTestContext();
|
|
||||||
var service = new StorageService(
|
|
||||||
db.Database,
|
|
||||||
seaweedFs.CreateClient(),
|
|
||||||
NullLogger<StorageService>.Instance,
|
|
||||||
seaweedFs.StorageConfig());
|
|
||||||
return (service, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MemoryStream TextStream(string content) => new(Encoding.UTF8.GetBytes(content));
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Upload_then_head_and_get_roundtrips_content()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var key = $"tests/{Guid.NewGuid()}.txt";
|
|
||||||
|
|
||||||
await service.UploadFileAsync(TextStream("hello seaweed"), "text/plain", Bucket, key, CancellationToken.None);
|
|
||||||
|
|
||||||
var metadata = await service.HeadObjectAsync(Bucket, key, CancellationToken.None);
|
|
||||||
metadata.Should().NotBeNull();
|
|
||||||
metadata!.ContentLength.Should().Be("hello seaweed".Length);
|
|
||||||
|
|
||||||
using var stream = await service.GetFileAsync(Bucket, key, CancellationToken.None);
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
(await reader.ReadToEndAsync()).Should().Be("hello seaweed");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Upload_persists_a_successful_upload_record()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var key = $"tests/{Guid.NewGuid()}.txt";
|
|
||||||
|
|
||||||
await service.UploadFileAsync(TextStream("data"), "text/plain", Bucket, key, CancellationToken.None);
|
|
||||||
|
|
||||||
var upload = db.NewContext().Uploads.Single(u => u.Key == key);
|
|
||||||
upload.State.Should().Be(UploadState.Successful);
|
|
||||||
upload.Bucket.Should().Be(Bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task HeadObject_returns_null_for_missing_object()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
|
|
||||||
var metadata = await service.HeadObjectAsync(Bucket, $"tests/missing-{Guid.NewGuid()}", CancellationToken.None);
|
|
||||||
|
|
||||||
metadata.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CopyFile_duplicates_the_object()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var source = $"tests/{Guid.NewGuid()}-src.txt";
|
|
||||||
var destination = $"tests/{Guid.NewGuid()}-dst.txt";
|
|
||||||
await service.UploadFileAsync(TextStream("copy me"), "text/plain", Bucket, source, CancellationToken.None);
|
|
||||||
|
|
||||||
await service.CopyFileAsync(Bucket, source, Bucket, destination, CancellationToken.None);
|
|
||||||
|
|
||||||
(await service.HeadObjectAsync(Bucket, destination, CancellationToken.None)).Should().NotBeNull();
|
|
||||||
(await service.HeadObjectAsync(Bucket, source, CancellationToken.None)).Should().NotBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RemoveFile_deletes_the_object()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var key = $"tests/{Guid.NewGuid()}.txt";
|
|
||||||
await service.UploadFileAsync(TextStream("delete me"), "text/plain", Bucket, key, CancellationToken.None);
|
|
||||||
|
|
||||||
await service.RemoveFileAsync(Bucket, key, CancellationToken.None);
|
|
||||||
|
|
||||||
(await service.HeadObjectAsync(Bucket, key, CancellationToken.None)).Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ListObjects_returns_objects_under_prefix()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var prefix = $"listing/{Guid.NewGuid()}";
|
|
||||||
await service.UploadFileAsync(TextStream("a"), "text/plain", Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
|
||||||
await service.UploadFileAsync(TextStream("b"), "text/plain", Bucket, $"{prefix}/b.txt", CancellationToken.None);
|
|
||||||
|
|
||||||
var keys = new List<string>();
|
|
||||||
await foreach (var obj in service.ListObjectsAsync(Bucket, prefix, CancellationToken.None))
|
|
||||||
keys.Add(obj.Key);
|
|
||||||
|
|
||||||
keys.Should().Contain($"{prefix}/a.txt").And.Contain($"{prefix}/b.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RemoveFolder_deletes_every_object_under_prefix()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
var prefix = $"folder/{Guid.NewGuid()}";
|
|
||||||
await service.UploadFileAsync(TextStream("a"), "text/plain", Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
|
||||||
await service.UploadFileAsync(TextStream("b"), "text/plain", Bucket, $"{prefix}/nested/b.txt", CancellationToken.None);
|
|
||||||
|
|
||||||
await service.RemoveFolderAsync(Bucket, prefix, CancellationToken.None);
|
|
||||||
|
|
||||||
var remaining = new List<StorageObject>();
|
|
||||||
await foreach (var obj in service.ListObjectsAsync(Bucket, prefix, CancellationToken.None))
|
|
||||||
remaining.Add(obj);
|
|
||||||
remaining.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetUploadUrl_returns_a_presigned_url()
|
|
||||||
{
|
|
||||||
var (service, db) = CreateService();
|
|
||||||
using var _ = db;
|
|
||||||
|
|
||||||
var url = await service.GetUploadUrlAsync(
|
|
||||||
Bucket, $"tests/{Guid.NewGuid()}.txt", "text/plain", TimeSpan.FromMinutes(5), CancellationToken.None);
|
|
||||||
|
|
||||||
url.Should().StartWith(seaweedFs.ServiceUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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])!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user