Add tests for MassTransit activities, routing slips, sagas, fault handling and playlist visibility
Infrastructure exposes its internals to the test project so the activities and slip builders can be exercised directly, and the fault consumer and state machines run on the MassTransit test harness.
This commit is contained in:
+199
@@ -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<User> 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<Album> 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<PlayList> 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<Track> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<MarkTrackLifeCycleActivity>.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<MarkAlbumLifeCycleActivity>.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<MarkPlayListLifeCycleActivity>.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<MarkTrackLifeCycleActivity>.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<DeleteTrackActivity>.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<DeleteAlbumActivity>.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<DeletePlayListActivity>.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<DeleteTrackActivity>.Instance);
|
||||
var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid()));
|
||||
|
||||
await activity.Execute(context);
|
||||
|
||||
context.Received(1).Completed();
|
||||
}
|
||||
|
||||
private static ExecuteContext<TArguments> ExecuteContextFor<TArguments>(TArguments arguments)
|
||||
where TArguments : class
|
||||
{
|
||||
var context = Substitute.For<ExecuteContext<TArguments>>();
|
||||
context.Arguments.Returns(arguments);
|
||||
context.CancellationToken.Returns(Ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private async Task<User> 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<Track> 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<Album> 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<PlayList> 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;
|
||||
}
|
||||
}
|
||||
+108
@@ -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<TrackPictureProcessingFailed>(
|
||||
RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId);
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() =>
|
||||
await AssertPublishedAsync<TrackAudioProcessingFailed>(
|
||||
RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId);
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() =>
|
||||
await AssertPublishedAsync<PlayListPictureProcessingFailed>(
|
||||
RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId);
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() =>
|
||||
await AssertPublishedAsync<AlbumPictureProcessingFailed>(
|
||||
RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId);
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() =>
|
||||
await AssertPublishedAsync<TrackProcessingFailed>(
|
||||
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<AlbumProcessingFailed>(
|
||||
RoutingSlipVariableNames.ProcessKinds.AlbumCreation,
|
||||
message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId);
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() =>
|
||||
await AssertPublishedAsync<PlayListProcessingFailed>(
|
||||
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<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables("Something else") }, Ct);
|
||||
|
||||
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
|
||||
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithoutProcessKind_PublishesNothing()
|
||||
{
|
||||
await using var provider = CreateProvider();
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = new Dictionary<string, object>() }, Ct);
|
||||
|
||||
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
|
||||
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
private static async Task AssertPublishedAsync<TEvent>(string processKind, Func<TEvent, bool> matches)
|
||||
where TEvent : class
|
||||
{
|
||||
await using var provider = CreateProvider();
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables(processKind) }, Ct);
|
||||
|
||||
Assert.True(await harness.Published.Any<TEvent>(published => matches(published.Context.Message), Ct));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> 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<ProcessingSlipFaultConsumer>())
|
||||
.BuildServiceProvider(true);
|
||||
}
|
||||
@@ -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<TrackProcessingStateMachine, TrackProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
|
||||
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<TrackProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
|
||||
{
|
||||
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
|
||||
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<TrackProcessingFailed>(
|
||||
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<TrackProcessingStateMachine, TrackProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
|
||||
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<TrackProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Track_EventForMissingInstance_IsDiscarded()
|
||||
{
|
||||
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
|
||||
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<TrackAudioProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<Fault<TrackPictureProcessed>>(Ct));
|
||||
Assert.False(await harness.Published.Any<Fault<TrackAudioProcessingFailed>>(Ct));
|
||||
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
// Album
|
||||
|
||||
[Fact]
|
||||
public async Task Album_PictureProcessed_CompletesTheSaga()
|
||||
{
|
||||
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
|
||||
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<AlbumProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Album_PictureFailed_PublishesProcessingFailed()
|
||||
{
|
||||
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
|
||||
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<AlbumProcessingFailed>(
|
||||
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<AlbumProcessingStateMachine, AlbumProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
|
||||
await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct);
|
||||
|
||||
Assert.True(await harness.Consumed.Any<AlbumPictureProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<AlbumProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<Fault<AlbumPictureProcessingFailed>>(Ct));
|
||||
}
|
||||
|
||||
// PlayList
|
||||
|
||||
[Fact]
|
||||
public async Task PlayList_PictureProcessed_CompletesTheSaga()
|
||||
{
|
||||
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
|
||||
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<PlayListProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayList_PictureFailed_PublishesProcessingFailed()
|
||||
{
|
||||
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
|
||||
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<PlayListProcessingFailed>(
|
||||
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<PlayListProcessingStateMachine, PlayListProcessingState>();
|
||||
var harness = await StartAsync(provider);
|
||||
|
||||
await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct);
|
||||
|
||||
Assert.True(await harness.Consumed.Any<PlayListPictureProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<PlayListProcessingFailed>(Ct));
|
||||
Assert.False(await harness.Published.Any<Fault<PlayListPictureProcessingFailed>>(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<ITestHarness> StartAsync(ServiceProvider provider)
|
||||
{
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
return harness;
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateProvider<TMachine, TState>()
|
||||
where TMachine : class, SagaStateMachine<TState>
|
||||
where TState : class, SagaStateMachineInstance =>
|
||||
new ServiceCollection()
|
||||
.AddMassTransitTestHarness(bus => bus.AddSagaStateMachine<TMachine, TState>().InMemoryRepository())
|
||||
.BuildServiceProvider(true);
|
||||
}
|
||||
@@ -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<string, object>(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal("source-key", copy["SourceKey"]?.ToString());
|
||||
Assert.Equal("final-key", copy["DestinationKey"]?.ToString());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Runs the picture-update activities inside real routing slips on the MassTransit test harness.</summary>
|
||||
[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<RoutingSlipCompleted>(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<AlbumPictureProcessed>(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<RoutingSlipCompleted>(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<PlayListPictureProcessed>(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<RoutingSlipCompleted>(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<TrackPictureProcessed>(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<RoutingSlipFaulted>(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<RoutingSlipFaulted>(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<RoutingSlipFaulted>(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<RoutingSlipFaulted>(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<Task<bool>> 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<IDatabase>(_ => fixture.CreateDatabase())
|
||||
.AddMassTransitTestHarness(bus =>
|
||||
{
|
||||
bus.SetKebabCaseEndpointNameFormatter();
|
||||
bus.AddActivity<UpdateAlbumPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
|
||||
bus.AddActivity<UpdatePlayListPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
|
||||
bus.AddActivity<UpdateTrackPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
|
||||
bus.AddExecuteActivity<AlwaysFailsActivity, AlwaysFailsArguments>();
|
||||
})
|
||||
.BuildServiceProvider(true);
|
||||
|
||||
private static async Task<ITestHarness> StartAsync(ServiceProvider provider)
|
||||
{
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
return harness;
|
||||
}
|
||||
|
||||
private async Task<User> 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<Album> 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<PlayList> 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<Track> 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<AlwaysFailsArguments>
|
||||
{
|
||||
public const string ExecuteEndpointName = "always-fails";
|
||||
|
||||
public Task<ExecutionResult> Execute(ExecuteContext<AlwaysFailsArguments> context) =>
|
||||
throw new InvalidOperationException("Forced failure");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user