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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user