Files
Musify/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs
T
Admin ebfd41b160 Add track publication, album types, optional track pictures and batch upload URLs
Tracks
- Track.IsPublished (migration marks existing tracks as published). New
  tracks are created unpublished.
- PUT /tracks/{id}/published and PUT /tracks/published change the flag;
  publishing requires completed audio processing (the batch endpoint
  reports skipped tracks).
- An unpublished track is only visible to its owner. A WherePublished /
  VisibleTo filter is applied to search, genres, listening history, last
  listened, likes, mixes, album and playlist contents and counts, recently
  listened albums, public user tracks, track details and stream tickets.
- isPublished is part of the track responses, GET /tracks/users/{userId}
  accepts a published filter and status-counts reports published and
  unpublished totals.
- CreateTrackRequest.pictureIntentId is optional: without it the track
  uses the album cover or the preset picture and no picture processing
  runs. CreateTrackResourcesEvent carries nullable picture fields, so
  queues must be drained before deploying.
- POST /tracks/upload-urls accepts an audios list and one optional picture
  shared by all of them, and returns an intent per file. The upload intent
  limits are raised so a batch fits.

Albums
- AlbumType (Album, EP, Single) on create, update and responses.
- CreateAlbumRequest.trackIds creates the album and links the tracks in a
  single save.
2026-09-25 01:37:55 +02:00

168 lines
8.6 KiB
C#

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 CreateTrack_WithPicture_CopiesBothFilesAndConsumesBothIntents()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, IntentId, audioIntentId, "bucket", "picture-source", "picture-final", "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyPictureToFinal, ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal("picture-final", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([IntentId, audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
}
[Fact]
public void CreateTrack_WithoutPicture_SkipsThePictureCopyAndOnlyConsumesTheAudioIntent()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, null, audioIntentId, "bucket", null, null, "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.False(slip.Variables.ContainsKey(RoutingSlipVariableNames.Workflow.PictureKey));
Assert.Equal("audio-final", slip.Variables[RoutingSlipVariableNames.Workflow.AudioKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[1].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
var publish = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Null(publish["FinalPictureKey"]);
}
[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());
}
}
}