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.
This commit is contained in:
2026-09-25 01:37:55 +02:00
parent d839a72700
commit ebfd41b160
79 changed files with 3076 additions and 151 deletions
@@ -0,0 +1,142 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class CreateAlbumWithTracksAndTypeTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateAlbumCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
return new CreateAlbumCommandHandler(
_eventBus,
Database,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Album());
}
private static UploadIntent PictureIntent(long userId) =>
TestEntities.UploadIntent(userId, UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
[Theory]
[InlineData(AlbumType.Album)]
[InlineData(AlbumType.EP)]
[InlineData(AlbumType.Single)]
public async Task Handle_StoresAndReturnsTheAlbumType(AlbumType type)
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Typed", null, null, intent.Id, type), Ct);
Assert.False(result.IsError);
Assert.Equal(type, result.Value.Type);
Assert.Equal(type, (await Database.Albums.AsNoTracking().SingleAsync(Ct)).Type);
}
[Fact]
public async Task Handle_DefaultsToAlbumType()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Default", null, null, intent.Id), Ct);
Assert.Equal(AlbumType.Album, result.Value.Type);
}
[Fact]
public async Task Handle_WithTrackIds_CreatesAlbumAndLinksTracksInOrderInOneSave()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", isPublished: false);
var third = TestEntities.Track(owner, "Third");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, first, second, third, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "With tracks", null, null, intent.Id, AlbumType.EP, [third.Id, first.Id, second.Id, third.Id]), Ct);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TrackCount);
Assert.Equal([third.Id, first.Id], result.Value.CoverTrackIds);
var links = await Database.AlbumHasTracks.AsNoTracking()
.Where(link => link.AlbumId == result.Value.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(Ct);
Assert.Equal([third.Id, first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([1, 2, 3], links.Select(link => link.TrackNumber));
await _eventBus.Received(1).PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndCreatesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var theirs = TestEntities.Track(stranger, "Theirs");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, stranger, theirs, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Stolen", null, null, intent.Id, TrackIds: [theirs.Id]), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
Assert.Empty(await Database.AlbumHasTracks.ToListAsync(Ct));
await _eventBus.DidNotReceive().PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MissingTrack_ReturnsNotFoundAndCreatesNothing()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Ghost", null, null, intent.Id, TrackIds: [Guid.NewGuid()]), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
}
[Fact]
public async Task Update_ChangesTypeOnlyWhenProvided()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.Type = AlbumType.EP;
await SeedAsync(owner, album);
var handler = new UpdateAlbumCommandHandler(Database, NoOpLogger<UpdateAlbumCommandHandler>());
var untouched = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024), Ct);
var changed = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024, AlbumType.Single), Ct);
Assert.Equal(AlbumType.EP, untouched.Value.Type);
Assert.Equal(AlbumType.Single, changed.Value.Type);
}
}
}
@@ -57,7 +57,8 @@ namespace Musify.Application.Tests.TestSupport
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null,
bool isExplicit = false)
bool isExplicit = false,
bool isPublished = true)
{
var track = new Track
{
@@ -66,6 +67,7 @@ namespace Musify.Application.Tests.TestSupport
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
IsPublished = isPublished,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
@@ -11,9 +11,9 @@ namespace Musify.Application.Tests.Tracks
{
public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private DeleteTracksCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTracksCommandHandler>());
private DeleteTracksCommandHandler CreateHandler() => new(Database, _eventBus, NoOpLogger<DeleteTracksCommandHandler>());
[Fact]
public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach()
@@ -29,7 +29,7 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(2, result.Value.DeletedTrackIds.Count);
Assert.Empty(result.Value.FailedTrackIds);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus));
await eventBus.Received(2).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
await _eventBus.Received(2).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
@@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus));
await eventBus.DidNotReceive().PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
await _eventBus.DidNotReceive().PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
@@ -75,7 +75,7 @@ namespace Musify.Application.Tests.Tracks
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken);
Assert.Single(result.Value.DeletedTrackIds);
await eventBus.Received(1).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
await _eventBus.Received(1).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
}
}
@@ -0,0 +1,119 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class RequestTrackUploadUrlsBatchTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private RequestTrackUploadUrlsCommandHandler CreateHandler()
{
_storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(call => $"https://storage.musify.test/{call.ArgAt<string>(1)}");
var config = TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler(
new UploadIntentIssuer(
Database,
_storageService,
new UploadIntentValidator(Database, _storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
TestConfigurations.Track(),
config);
}
private static readonly AudioUploadSpec[] _threeAudios =
[
new("mp3", "audio/mpeg", 3_000_000),
new("flac", "audio/flac", 30_000_000),
new("wav", "audio/wav", 40_000_000)
];
[Fact]
public async Task Handle_ManyAudiosAndSharedPicture_IssuesOneAudioIntentPerFileAndOnePictureIntentPerTrackOnASingleObject()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Equal(3, result.Value.Audios.Count);
Assert.All(result.Value.Audios, audio => Assert.NotNull(audio.PictureIntentId));
var intents = await Database.UploadIntents.AsNoTracking().ToListAsync(Ct);
var pictures = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackPicture).ToList();
var audios = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackAudio).ToList();
Assert.Equal(3, pictures.Count);
Assert.Equal(3, audios.Count);
Assert.Single(pictures.Select(intent => intent.Key).Distinct());
Assert.Equal(3, pictures.Select(intent => intent.ObjectName).Distinct().Count());
Assert.Equal(3, audios.Select(intent => intent.Key).Distinct().Count());
Assert.Equal(
result.Value.Audios.Select(audio => audio.PictureIntentId!.Value).Order(),
pictures.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios.Select(audio => audio.IntentId).Order(), audios.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios[0].IntentId, result.Value.AudioIntentId);
Assert.Equal(result.Value.Audios[0].PictureIntentId, result.Value.PictureIntentId);
Assert.Equal(pictures[0].Key, result.Value.PictureKey);
}
[Fact]
public async Task Handle_ManyAudiosWithoutPicture_IssuesNoPictureIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, null, null, Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Null(result.Value.PictureIntentId);
Assert.Null(result.Value.PictureUploadUrl);
Assert.All(result.Value.Audios, audio => Assert.Null(audio.PictureIntentId));
Assert.All(await Database.UploadIntents.ToListAsync(Ct), intent => Assert.Equal(UploadIntentPurpose.TrackAudio, intent.Purpose));
}
[Fact]
public async Task Handle_LegacyAudioFields_StillReturnASingleItem()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"), Ct);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Audios);
Assert.Equal(result.Value.AudioIntentId, item.IntentId);
Assert.Equal(result.Value.PictureIntentId, item.PictureIntentId);
}
[Fact]
public async Task Handle_NoAudio_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp"), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(Ct));
}
}
}
@@ -0,0 +1,146 @@
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));
}
}
}
@@ -0,0 +1,152 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class TrackCreationWithoutPictureTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("audio/mpeg", 1024));
return new CreateTrackCommandHandler(
Database,
_eventBus,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Track());
}
[Fact]
public async Task CreateTrack_WithoutPicture_UsesPresetsAndSkipsPictureProcessing()
{
var user = TestEntities.User();
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, audio);
var routes = TestConfigurations.Track().Routes;
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "No cover", null, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Null(stored.Pictures.OriginalName);
Assert.Equal(routes.PresetSmallPicture, stored.Pictures.SmallName);
Assert.Equal(routes.PresetMediumPicture, stored.Pictures.MediumName);
Assert.Equal(routes.PresetLargePicture, stored.Pictures.LargeName);
Assert.Equal(ProcessingStatus.Completed, stored.Pictures.ProcessingStatus);
Assert.False(stored.IsPublished);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message =>
message.PictureIntentId == null
&& message.PictureSourceKey == null
&& message.PictureDestinationKey == null
&& message.AudioIntentId == audio.Id),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreateTrack_WithPicture_StaysUnpublishedAndKeepsPictureFlow()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "Cover", picture.Id, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal(ProcessingStatus.Pending, stored.Pictures.ProcessingStatus);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message => message.PictureIntentId == picture.Id && message.PictureSourceKey != null),
Arg.Any<CancellationToken>());
}
private GetCoverQueryHandler CreateCoverHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Fact]
public async Task Cover_TrackWithoutPictureInAnAlbum_UsesTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
var album = TestEntities.Album(owner.Id, pictures: new EntityPictures
{
OriginalName = "album-original.webp",
SmallName = "album-small.webp",
MediumName = "album-medium.webp",
LargeName = "album-large.webp"
});
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("album-small.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithoutPictureAndAlbum_FallsBackToThePreset()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
await SeedAsync(owner, track);
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("PresetSmallPicture.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithOwnPicture_IgnoresTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.EndsWith("small.webp", result.Value.Key);
Assert.DoesNotContain("album", result.Value.Key, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,259 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Genres;
using Musify.Application.Likes;
using Musify.Application.Mixes;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class UnpublishedTrackVisibilityTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<(User Owner, User Other, Track Live, Track Hidden)> SeedTracksAsync()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var live = TestEntities.Track(owner, "Live");
var hidden = TestEntities.Track(owner, "Hidden", isPublished: false, tags: [Genre.Jazz]);
await SeedAsync(owner, other, live, hidden);
return (owner, other, live, hidden);
}
[Fact]
public async Task Search_HidesUnpublishedTracksEvenFromTheOwner()
{
var (_, _, live, _) = await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20), Ct);
Assert.Equal([live.Id], result.Items.Select(t => t.Id));
}
[Fact]
public async Task Search_ByGenre_HidesUnpublishedTracks()
{
await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20, Genre.Jazz), Ct);
Assert.Empty(result.Items);
}
[Fact]
public async Task UserTracks_OwnerSeesUnpublishedAndCanFilterThem()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var handler = new GetTracksByUserIdQueryHandler(Database);
var all = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id), Ct);
var onlyHidden = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: false), Ct);
var onlyLive = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: true), Ct);
Assert.Equal(2, all.Value.TotalItemCount);
Assert.Equal([hidden.Id], onlyHidden.Value.Items.Select(t => t.Id));
Assert.False(onlyHidden.Value.Items[0].IsPublished == true);
Assert.Equal([live.Id], onlyLive.Value.Items.Select(t => t.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UserTracks_OtherViewersAndAnonymousOnlySeePublished(bool signedIn)
{
var (owner, other, live, _) = await SeedTracksAsync();
var result = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null, IsPublished: false), Ct);
Assert.Empty(result.Value.Items);
var all = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null), Ct);
Assert.Equal([live.Id], all.Value.Items.Select(t => t.Id));
}
[Fact]
public async Task TrackById_OnlyTheOwnerCanReadAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var handler = new GetTrackByIdQueryHandler(Database);
var asOwner = await handler.Handle(new GetTrackByIdQuery(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new GetTrackByIdQuery(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new GetTrackByIdQuery(hidden.Id), Ct);
Assert.False(asOwner.IsError);
Assert.False(asOwner.Value.IsPublished);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
}
[Fact]
public async Task StartListening_OnlyTheOwnerCanPlayAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var ticketService = Substitute.For<IStreamTicketService>();
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket", 60));
var playback = TestConfigurations.Playback(allowAnonymousListening: true);
var issuer = new TrackStreamIssuer(ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
var handler = new StartListeningCommandHandler(Database, issuer, playback, NoOpLogger<StartListeningCommandHandler>());
var asOwner = await handler.Handle(new StartListeningCommand(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new StartListeningCommand(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new StartListeningCommand(hidden.Id, null), Ct);
Assert.False(asOwner.IsError);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
Assert.Single(await Database.ListeningHistories.ToListAsync(Ct));
}
[Fact]
public async Task Genres_CountOnlyPublishedTracks()
{
await SeedTracksAsync();
var genres = await new GetGenresQueryHandler(Database).Handle(new GetGenresQuery(), Ct);
var pop = Assert.Single(genres);
Assert.Equal(Genre.Pop, pop.Genre);
Assert.Equal(1, pop.TrackCount);
}
[Fact]
public async Task ListeningHistory_AndLastListened_SkipUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await SeedAsync(
TestEntities.ListeningHistory(owner.Id, live.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, hidden.Id, DateTime.UtcNow));
var history = await new GetListeningHistoryQueryHandler(Database).Handle(new GetListeningHistoryQuery(owner.Id), Ct);
var last = await new GetLastTrackListenedByUserIdQueryHandler(Database).Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], history.Select(t => t.Id));
Assert.Equal(live.Id, last?.Id);
}
[Fact]
public async Task Likes_UnpublishedTracksCannotBeLikedAndDisappearFromTheLikedList()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var toggle = new ToggleTrackLikeCommandHandler(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
var likeHidden = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, hidden.Id), Ct);
var likeLive = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, live.Id), Ct);
await SeedAsync(new TrackLike { UserId = owner.Id, TrackId = hidden.Id });
var likedByOwner = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(owner.Id, 1, 20), Ct);
var likedByOther = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(other.Id, 1, 20), Ct);
Assert.Equal(ErrorType.NotFound, likeHidden.FirstError.Type);
Assert.False(likeLive.IsError);
Assert.Empty(likedByOwner.Items);
Assert.Equal([live.Id], likedByOther.Items.Select(t => t.Id));
}
[Fact]
public async Task Mixes_OnlyUseAndShowPublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await new GenerateMixesForUserCommandHandler(Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>())
.Handle(new GenerateMixesForUserCommand(owner.Id), Ct);
var generated = await Database.MixItems.Select(item => item.TrackId).Distinct().ToListAsync(Ct);
Assert.Equal([live.Id], generated);
var mix = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 5);
await SeedAsync(mix, TestEntities.MixItem(mix.Id, live.Id, 0), TestEntities.MixItem(mix.Id, hidden.Id, 1));
var byId = await new GetMixByIdQueryHandler(Database).Handle(new GetMixByIdQuery(owner.Id, mix.Id), Ct);
var list = await new GetMixesByUserIdQueryHandler(Database).Handle(new GetMixesByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], byId.Value.Items.Select(i => i.TrackId));
Assert.Equal(1, byId.Value.ItemCount);
var listed = list.Single(m => m.Id == mix.Id);
Assert.Equal(1, listed.ItemCount);
Assert.Equal([live.Id], listed.Items.Select(i => i.TrackId));
}
[Fact]
public async Task Albums_HideUnpublishedTracksEverywhere()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
await SeedAsync(
album,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 },
TestEntities.ListeningHistory(other.Id, hidden.Id));
var tracks = await new GetAlbumTracksQueryHandler(Database).Handle(new GetAlbumTracksQuery(album.Id, 1, 20), Ct);
var detail = await new GetAlbumByIdQueryHandler(Database).Handle(new GetAlbumByIdQuery(album.Id), Ct);
var byUser = await new GetAlbumsByUserIdQueryHandler(Database).Handle(new GetAlbumsByUserIdQuery(owner.Id, 1, 20), Ct);
var all = await new GetAlbumsQueryHandler(Database).Handle(new GetAlbumsQuery(null, 1, 20), Ct);
var recent = await new GetRecentlyListenedAlbumsQueryHandler(Database).Handle(new GetRecentlyListenedAlbumsQuery(other.Id, 10), Ct);
var ids = await new GetAlbumTrackIdsQueryHandler(Database).Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(1, byUser.Value.Items.Single().TrackCount);
Assert.Equal(1, all.Items.Single().TrackCount);
Assert.Empty(recent);
Assert.Equal([hidden.Id, live.Id], ids.Value.TrackIds);
}
[Fact]
public async Task PlayLists_HideUnpublishedTracksAndRejectAddingThem()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Public);
await SeedAsync(
playList,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = hidden.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = live.Id, Position = 1 });
var tracks = await new GetPlayListTracksQueryHandler(Database).Handle(new GetPlayListTracksQuery(playList.Id, 1, 20, owner.Id), Ct);
var detail = await new GetPlayListByIdQueryHandler(Database).Handle(new GetPlayListByIdQuery(playList.Id, other.Id), Ct);
var add = await new AddTracksToPlayListCommandHandler(Database, NoOpLogger<AddTracksToPlayListCommandHandler>())
.Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [hidden.Id]), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal(live.DurationSeconds, detail.Value.DurationSeconds);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(ErrorType.NotFound, add.FirstError.Type);
}
[Fact]
public async Task AddAlbumToPlayList_SkipsUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(
album, playList,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 });
var result = await new AddAlbumToPlayListCommandHandler(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>())
.Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), Ct);
Assert.Equal(1, result.Value.AddedCount);
Assert.Equal([live.Id], await Database.PlayListHasTracks.Select(link => link.TrackId).ToListAsync(Ct));
}
}
}