Files
Musify/backend/Tests/Musify.Application.Tests/Tracks/SetTrackPublishedCommandHandlerTests.cs
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

147 lines
6.5 KiB
C#

using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class SetTrackPublishedCommandHandlerTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private SetTrackPublishedCommandHandler CreateSingleHandler() => new(Database, NoOpLogger<SetTrackPublishedCommandHandler>());
private SetTracksPublishedCommandHandler CreateBatchHandler() => new(Database, NoOpLogger<SetTracksPublishedCommandHandler>());
private async Task<bool> IsPublishedAsync(Guid trackId) =>
(await Database.Tracks.AsNoTracking().SingleAsync(t => t.Id == trackId, Ct)).IsPublished;
[Fact]
public async Task Single_ProcessedTrack_CanBePublishedAndUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, track);
var publish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
var published = await IsPublishedAsync(track.Id);
var unpublish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(publish.IsError);
Assert.True(published);
Assert.False(unpublish.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Theory]
[InlineData(ProcessingStatus.Pending)]
[InlineData(ProcessingStatus.Processing)]
[InlineData(ProcessingStatus.Failed)]
public async Task Single_AudioNotCompleted_CannotBePublished(ProcessingStatus status)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: new Domain.ValueObjects.TrackAudio { TranscodeStatus = status }, isPublished: false);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_UnprocessedTrackThatIsPublished_CanStillBeUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio(), isPublished: true);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(result.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_NotTheOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, stranger, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(stranger.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_MissingOrRemovingTrack_ReturnsNotFound()
{
var owner = TestEntities.User();
var removing = TestEntities.Track(owner, lifeCycleStatus: LifeCycleStatus.Removing, isPublished: false);
await SeedAsync(owner, removing);
var missing = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, Guid.NewGuid(), true), Ct);
var gone = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, removing.Id, true), Ct);
Assert.Equal(ErrorType.NotFound, missing.FirstError.Type);
Assert.Equal(ErrorType.NotFound, gone.FirstError.Type);
}
[Fact]
public async Task Batch_PublishesProcessedTracksAndSkipsTheRest()
{
var owner = TestEntities.User();
var ready = TestEntities.Track(owner, "Ready", isPublished: false);
var processing = TestEntities.Track(owner, "Processing", audio: TestEntities.PendingAudio(), isPublished: false);
var missing = Guid.NewGuid();
await SeedAsync(owner, ready, processing);
var result = await CreateBatchHandler().Handle(
new SetTracksPublishedCommand(owner.Id, [ready.Id, processing.Id, missing, ready.Id], true), Ct);
Assert.False(result.IsError);
Assert.Equal([ready.Id], result.Value.UpdatedTrackIds);
Assert.Equal([processing.Id, missing], result.Value.SkippedTrackIds);
Assert.True(await IsPublishedAsync(ready.Id));
Assert.False(await IsPublishedAsync(processing.Id));
}
[Fact]
public async Task Batch_Unpublish_AppliesToEveryOwnedTrack()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", audio: TestEntities.PendingAudio());
await SeedAsync(owner, first, second);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [first.Id, second.Id], false), Ct);
Assert.Equal(2, result.Value.UpdatedTrackIds.Count);
Assert.Empty(result.Value.SkippedTrackIds);
Assert.False(await IsPublishedAsync(first.Id));
Assert.False(await IsPublishedAsync(second.Id));
}
[Fact]
public async Task Batch_TrackOfAnotherUser_ReturnsForbiddenAndChangesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var mine = TestEntities.Track(owner, "Mine", isPublished: false);
var theirs = TestEntities.Track(stranger, "Theirs", isPublished: false);
await SeedAsync(owner, stranger, mine, theirs);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [mine.Id, theirs.Id], true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(mine.Id));
Assert.False(await IsPublishedAsync(theirs.Id));
}
}
}