diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index a24d3fc..90108ae 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -42,6 +42,12 @@ + + + + + + diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs new file mode 100644 index 0000000..faa9eda --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Api.Tests.TestSupport; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class PlayListEndpointsTests(ApiTestFixture fixture) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) + { + var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( + "/playlists", + new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), + Ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; + } + + [Fact] + public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); + var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); + } + + [Fact] + public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + } + + [Fact] + public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); + var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); + + Assert.Equal([publicPlayList.Id], asStranger); + Assert.Contains(publicPlayList.Id, asOwner); + Assert.Contains(privatePlayList.Id, asOwner); + } + + [Fact] + public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task UpdatePlayList_Owner_RenamesIt() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); + Assert.Equal("Renamed", body!.Name); + } + + [Fact] + public async Task GetPlayListById_NotAGuid_ReturnsNotFound() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() + { + var owner = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( + "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) + { + var response = await client.GetAsync($"/playlists/users/{userId}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); + return page!.Items.Select(playList => playList.Id).ToList(); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs new file mode 100644 index 0000000..248e062 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs @@ -0,0 +1,22 @@ +using Musify.Infrastructure.Jobs; +using Xunit; + +namespace Musify.Infrastructure.Tests.Jobs; + +public sealed class RecurringJobsRegistrarTests +{ + [Theory] + [InlineData(1, "*/1 * * * *")] + [InlineData(59, "*/1 * * * *")] + [InlineData(60, "*/1 * * * *")] + [InlineData(61, "*/2 * * * *")] + [InlineData(300, "*/5 * * * *")] + [InlineData(3599, "0 */1 * * *")] + [InlineData(3600, "0 */1 * * *")] + [InlineData(7200, "0 */2 * * *")] + [InlineData(0, "*/1 * * * *")] + public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + { + Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs new file mode 100644 index 0000000..a2b7af5 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -0,0 +1,199 @@ +using Musify.Application.Configuration; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +[Collection(InfrastructureCollection.Name)] +public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() + { + var album = await SeedAlbumAsync(new EntityPictures + { + OriginalName = "original.webp", SmallName = "small.webp", MediumName = "medium.webp", LargeName = "large.webp" + }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkAlbumAsRemoving, + ActivityNames.RemoveAlbumOriginalPicture, + ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, + ActivityNames.RemoveAlbumLargePicture, + ActivityNames.DeleteAlbumFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() + { + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() + { + var playList = await SeedPlayListAsync(pictures: null); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() + { + var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkPlayListAsRemoving, + ActivityNames.RemovePlayListOriginalPicture, + ActivityNames.RemovePlayListSmallPicture, + ActivityNames.DeletePlayListFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() + { + var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() + { + var track = await SeedTrackAsync( + new TrackPictures + { + OriginalName = "original.webp", SmallName = "s.webp", MediumName = "m.webp", LargeName = "l.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackSmallPicture, + ActivityNames.RemoveTrackMediumPicture, + ActivityNames.RemoveTrackLargePicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => + new( + database, + new TrackConfiguration(), + new AlbumConfiguration(), + new PlayListConfiguration(), + new ApplicationStorageConfiguration { Bucket = "bucket" }); + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"delete-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = pictures, + Audio = audio + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs new file mode 100644 index 0000000..2d73fa0 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs @@ -0,0 +1,185 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.LifeCycle; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +[Collection(InfrastructureCollection.Name)] +public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task MarkTrack_ExistingTrack_SetsTheStatus() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task Mark_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + [Fact] + public async Task DeleteTrack_ExistingTrack_RemovesTheRow() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); + } + + [Fact] + public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteAlbumActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); + } + + [Fact] + public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeletePlayListActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); + } + + [Fact] + public async Task Delete_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + private static ExecuteContext ExecuteContextFor(TArguments arguments) + where TArguments : class + { + var context = Substitute.For>(); + context.Arguments.Returns(arguments); + context.CancellationToken.Returns(Ct); + return context; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + private async Task SeedAlbumAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs new file mode 100644 index 0000000..590b01c --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs @@ -0,0 +1,108 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class ProcessingSlipFaultConsumerTests +{ + private static readonly Guid SubjectId = Guid.NewGuid(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); + + [Fact] + public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_UnknownKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Consume_WithoutProcessKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + private static async Task AssertPublishedAsync(string processKind, Func matches) + where TEvent : class + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); + + Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); + } + + private static Dictionary Variables(string processKind) => new() + { + [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, + [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, + [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", + [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", + [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", + }; + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddConsumer()) + .BuildServiceProvider(true); +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs new file mode 100644 index 0000000..a86a199 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -0,0 +1,207 @@ +using MassTransit; +using MassTransit.Testing; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.MassTransit.Sagas; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class ProcessingStateMachineTests +{ + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Track + + [Fact] + public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } + && published.Context.Message.TrackId == id, Ct)); + } + + [Fact] + public async Task Track_AudioFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + // Album + + [Fact] + public async Task Album_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Album_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.AlbumId == id, Ct)); + } + + [Fact] + public async Task Album_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + // PlayList + + [Fact] + public async Task PlayList_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task PlayList_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.PlayListId == id, Ct)); + } + + [Fact] + public async Task PlayList_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + private static CreateTrackResourcesEvent TrackStarted(Guid id) => + new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private static ServiceProvider CreateProvider() + where TMachine : class, SagaStateMachine + where TState : class, SagaStateMachineInstance => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) + .BuildServiceProvider(true); +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs new file mode 100644 index 0000000..b35c96b --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -0,0 +1,129 @@ +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +public sealed class RoutingSlipBuilderTests +{ + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); + + private static readonly Guid SubjectId = Guid.NewGuid(); + private static readonly Guid IntentId = Guid.NewGuid(); + private static readonly Guid CorrelationId = Guid.NewGuid(); + + [Fact] + public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + { + var slip = RoutingSlips.Create(CorrelationId).Build(); + + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + var subscription = Assert.Single(slip.Subscriptions); + Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); + Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); + } + + [Fact] + public void Create_WithoutCorrelationId_UsesEmptyGuid() + { + var slip = RoutingSlips.Create(null).Build(); + + Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + } + + [Fact] + public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() + { + var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); + + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Contains(slip.Subscriptions, subscription => + subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) + && subscription.Events == RoutingSlipEvents.Faulted); + } + + [Fact] + public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() + { + var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() + { + var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); + } + + [Fact] + public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() + { + var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() + { + var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); + } + + [Fact] + public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() + { + var configuration = new WorkerConfiguration(); + var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); + + var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); + + Assert.Equal( + [ + ActivityNames.GenerateAudioWorkflowPaths, + ActivityNames.DownloadFile, + ActivityNames.TranscodeAudio, + ActivityNames.TransferFiles, + ActivityNames.UpdateTrackAudio, + ], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); + } + + private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) + { + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], + slip.Itinerary.Select(activity => activity.Name)); + + Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); + Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + + var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal("source-key", copy["SourceKey"]?.ToString()); + Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs new file mode 100644 index 0000000..3378b39 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs @@ -0,0 +1,260 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using MassTransit.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Activities.Pictures; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.MassTransit; + +/// Runs the picture-update activities inside real routing slips on the MassTransit test harness. +[Collection(InfrastructureCollection.Name)] +public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) +{ + private const string SmallVariable = "Picture.Small"; + private const string MediumVariable = "Picture.Medium"; + private const string LargeVariable = "Picture.Large"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("new-original.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.Equal("medium.webp", stored.MediumName); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); + } + + [Fact] + public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() + { + var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; + Assert.Equal("new.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); + } + + [Fact] + public async Task Track_Execute_MarksThePicturesAsCompleted() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); + } + + [Fact] + public async Task Execute_MissingEntity_FaultsTheSlip() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + } + + [Fact] + public async Task Execute_MissingVariable_FaultsTheSlip() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = RoutingSlips.Create(null).AddStep( + "Update", + UpdateAlbumPictureActivity.ExecuteEndpointName, + new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); + } + + [Fact] + public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("old-original.webp", stored.OriginalName); + Assert.Null(stored.SmallName); + Assert.Null(stored.MediumName); + Assert.Null(stored.LargeName); + } + + [Fact] + public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); + Assert.Equal("old.webp", stored.OriginalName); + } + + private static async Task WaitUntilAsync(Func> condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(100, Ct); + } + + private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) + { + var builder = RoutingSlips.Create(null).AddStep( + "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); + builder.AddVariable(SmallVariable, "pictures/small.webp"); + builder.AddVariable(MediumVariable, "pictures/medium.webp"); + builder.AddVariable(LargeVariable, "pictures/large.webp"); + return builder; + } + + private ServiceProvider CreateProvider() => + new ServiceCollection() + .AddScoped(_ => fixture.CreateDatabase()) + .AddMassTransitTestHarness(bus => + { + bus.SetKebabCaseEndpointNameFormatter(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddExecuteActivity(); + }) + .BuildServiceProvider(true); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"pictures-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures { OriginalName = "old.webp" }, + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + public sealed record AlwaysFailsArguments; + + public sealed class AlwaysFailsActivity : IExecuteActivity + { + public const string ExecuteEndpointName = "always-fails"; + + public Task Execute(ExecuteContext context) => + throw new InvalidOperationException("Forced failure"); + } +}