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
@@ -1,5 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Tests.TestSupport;
@@ -14,6 +16,11 @@ namespace Musify.Api.Tests.Endpoints
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
@@ -54,7 +61,7 @@ namespace Musify.Api.Tests.Endpoints
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId);
@@ -89,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -104,7 +111,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -119,7 +126,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -134,7 +141,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await ownerClient.PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken);
@@ -149,7 +156,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
@@ -34,6 +34,7 @@ namespace Musify.Api.Tests.Endpoints
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
IsPublished = true,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
@@ -19,7 +19,8 @@ namespace Musify.Api.Tests.Endpoints
private async Task<Guid> SeedTrackAsync(
long ownerId,
string title,
ProcessingStatus status = ProcessingStatus.Completed)
ProcessingStatus status = ProcessingStatus.Completed,
bool isPublished = true)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
@@ -28,6 +29,7 @@ namespace Musify.Api.Tests.Endpoints
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = ownerId,
IsPublished = isPublished,
Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
};
database.Tracks.Add(track);
@@ -0,0 +1,298 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class TrackPublicationEndpointsTests(ApiTestFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<Guid> SeedTrackAsync(
long ownerId,
string title,
ProcessingStatus status = ProcessingStatus.Completed,
bool isPublished = true)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = ownerId,
IsPublished = isPublished,
Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
};
database.Tracks.Add(track);
await database.SaveChangesAsync(Ct);
return track.Id;
}
private async Task<Guid> SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId };
database.Albums.Add(album);
var number = 1;
foreach (var trackId in trackIds)
database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ });
await database.SaveChangesAsync(Ct);
return album.Id;
}
private void MockStorage()
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
}
private static int Number(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? int.Parse(element.GetString()!, CultureInfo.InvariantCulture)
: element.GetInt32();
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync(Ct);
return JsonDocument.Parse(text).RootElement.Clone();
}
private static string UserPath(long userId) => $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}";
[Fact]
public async Task PutPublished_Owner_PublishesTheTrackForEveryone()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var anonymous = fixture.CreateAnonymousClient();
var before = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
var publish = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var after = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
Assert.Equal(HttpStatusCode.NotFound, before.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, publish.StatusCode);
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
Assert.True((await ReadJsonAsync(after)).GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublished_AudioNotProcessed_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Processing, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PutPublished_NotTheOwnerOrAnonymous_IsRejected()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(owner, "Mine", isPublished: false);
var asStranger = await fixture.CreateAuthenticatedClient(stranger)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var anonymous = await fixture.CreateAnonymousClient()
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.Forbidden, asStranger.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task PutPublishedBatch_UpdatesProcessedTracksAndReportsTheSkippedOnes()
{
var userId = await fixture.SeedUserAsync();
var ready = await SeedTrackAsync(userId, "Ready", isPublished: false);
var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([ready, busy], true), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal([ready], body.GetProperty("updatedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
Assert.Equal([busy], body.GetProperty("skippedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
}
[Fact]
public async Task PutPublishedBatch_EmptyList_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([], true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetUserTracks_UnpublishedTracksOnlyReachTheOwner()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
var path = UserPath(userId);
var anonymous = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync(path, Ct));
var asStranger = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(stranger).GetAsync(path, Ct));
var asOwner = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync(path, Ct));
var onlyHidden = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync($"{path}?published=false", Ct));
Assert.Equal(1, Number(anonymous.GetProperty("totalItemCount")));
Assert.Equal(1, Number(asStranger.GetProperty("totalItemCount")));
Assert.Equal(2, Number(asOwner.GetProperty("totalItemCount")));
Assert.Equal("Hidden", onlyHidden.GetProperty("items")[0].GetProperty("title").GetString());
Assert.False(onlyHidden.GetProperty("items")[0].GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task StatusCounts_SplitPublishedFromUnpublished()
{
var userId = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"{UserPath(userId)}/status-counts", Ct);
var body = await ReadJsonAsync(response);
Assert.Equal(1, Number(body.GetProperty("published")));
Assert.Equal(2, Number(body.GetProperty("unpublished")));
}
[Fact]
public async Task Search_AndStream_IgnoreUnpublishedTracks()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var title = $"Secret{Guid.NewGuid():N}";
var trackId = await SeedTrackAsync(owner, title, isPublished: false);
var search = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks?name={title}", Ct));
var stream = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/tracks/{trackId}/stream", Ct);
Assert.Equal(0, Number(search.GetProperty("totalItemCount")));
Assert.Equal(HttpStatusCode.NotFound, stream.StatusCode);
}
[Fact]
public async Task GetAlbum_HidesUnpublishedTracksFromTheListAndTheCount()
{
var userId = await fixture.SeedUserAsync();
var live = await SeedTrackAsync(userId, "Live");
var hidden = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var albumId = await SeedAlbumWithTracksAsync(userId, hidden, live);
var tracks = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}/tracks", Ct));
var album = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}", Ct));
Assert.Equal(1, Number(tracks.GetProperty("totalItemCount")));
Assert.Equal(1, Number(album.GetProperty("trackCount")));
Assert.Equal("Album", album.GetProperty("type").GetString());
}
[Fact]
public async Task PostUploadUrls_ManyAudiosAndSharedPicture_ReturnsAnIntentPerFile()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var request = new RequestTrackUploadUrlsRequest(
"webp", "image/webp", Audios: [new("mp3", "audio/mpeg", 1000), new("flac", "audio/flac", 2000)]);
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync("/tracks/upload-urls", request, Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
var audios = body.GetProperty("audios").EnumerateArray().ToList();
Assert.Equal(2, audios.Count);
Assert.All(audios, audio => Assert.NotEqual(JsonValueKind.Null, audio.GetProperty("pictureIntentId").ValueKind));
Assert.Equal(2, audios.Select(audio => audio.GetProperty("intentId").GetGuid()).Distinct().Count());
Assert.Equal(2, audios.Select(audio => audio.GetProperty("pictureIntentId").GetGuid()).Distinct().Count());
}
[Fact]
public async Task PostUploadUrls_AudiosOnly_HasNoPictureIntent()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync(
"/tracks/upload-urls", new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal(JsonValueKind.Null, body.GetProperty("pictureIntentId").ValueKind);
Assert.Equal(JsonValueKind.Null, body.GetProperty("audios")[0].GetProperty("pictureIntentId").ValueKind);
}
[Fact]
public async Task PostAlbum_WithTypeAndTracks_LinksThemAndPutChangesTheType()
{
var userId = await fixture.SeedUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var first = await SeedTrackAsync(userId, "First");
var second = await SeedTrackAsync(userId, "Second");
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var intentId = upload.GetProperty("intentId").GetGuid();
var created = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Typed", null, 2024, intentId, AlbumType.EP, [second, first]), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var album = await ReadJsonAsync(created);
Assert.Equal("EP", album.GetProperty("type").GetString());
Assert.Equal(2, Number(album.GetProperty("trackCount")));
var albumId = album.GetProperty("id").GetGuid();
var ids = await ReadJsonAsync(await client.GetAsync($"/albums/{albumId}/tracks/ids", Ct));
Assert.Equal([second, first], ids.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
var updated = await client.PutAsJsonAsync($"/albums/{albumId}", new UpdateAlbumRequest("Typed", null, 2024, AlbumType.Single), Ct);
Assert.Equal("Single", (await ReadJsonAsync(updated)).GetProperty("type").GetString());
}
[Fact]
public async Task PostAlbum_WithTrackOfAnotherUser_ReturnsForbidden()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var theirs = await SeedTrackAsync(stranger, "Theirs");
var client = fixture.CreateAuthenticatedClient(userId);
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var response = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Stolen", null, null, upload.GetProperty("intentId").GetGuid(), TrackIds: [theirs]), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
@@ -31,7 +31,8 @@ namespace Musify.Api.Tests.Endpoints
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner
Owner = owner,
IsPublished = true
};
database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory
@@ -0,0 +1,122 @@
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Validators.Albums;
using Musify.Api.Validators.Tracks;
using Musify.Application.Configuration;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Api.Tests.Validators
{
public sealed class LibraryRequestValidatorTests
{
private readonly UploadIntentConfiguration _configuration = new();
[Fact]
public void CreateTrack_WithoutPicture_Passes()
{
var request = new CreateTrackRequest("Song", null, Guid.NewGuid(), [Genre.Pop]);
Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void CreateTrack_EmptyPictureIntent_Fails()
{
var request = new CreateTrackRequest("Song", Guid.Empty, Guid.NewGuid(), [Genre.Pop]);
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void UploadUrls_ManyAudiosWithSharedPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(
"png", "image/png", Audios: [new("mp3", "audio/mpeg", 10), new("flac", "audio/flac")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_AudiosWithoutPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_NoAudioAtAll_Fails()
{
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest("png", "image/png")).IsValid);
}
[Fact]
public void UploadUrls_LegacyAudioTogetherWithAudiosList_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_HalfAPicture_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", null, Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_InvalidAudioInTheList_Fails()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg"), new("png", "image/png")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_TooManyAudios_Fails()
{
var audios = Enumerable.Range(0, RequestTrackUploadUrlsRequestValidator.MaxAudiosPerRequest + 1)
.Select(_ => new AudioUploadRequest("mp3", "audio/mpeg"))
.ToList();
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest(Audios: audios)).IsValid);
}
[Fact]
public void SetTracksPublished_RequiresBetweenOneAndTheMaximumTracks()
{
var validator = new SetTracksPublishedRequestValidator();
var tooMany = Enumerable.Range(0, SetTracksPublishedRequestValidator.MaxTracksPerRequest + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new SetTracksPublishedRequest([Guid.NewGuid()], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest(tooMany, true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([Guid.Empty], true)).IsValid);
}
[Fact]
public void CreateAlbum_UnknownTypeOrTooManyTracks_Fails()
{
var validator = new CreateAlbumRequestValidator();
var tooMany = Enumerable.Range(0, CreateAlbumRequestValidator.MaxTracksOnCreate + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), AlbumType.EP, [Guid.NewGuid()])).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), (AlbumType)42)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: tooMany)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: [Guid.Empty])).IsValid);
}
[Fact]
public void UpdateAlbum_TypeIsOptionalButMustBeKnown()
{
var validator = new UpdateAlbumRequestValidator();
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null)).IsValid);
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null, AlbumType.Single)).IsValid);
Assert.False(validator.Validate(new UpdateAlbumRequest("Album", null, null, (AlbumType)42)).IsValid);
}
}
}
@@ -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));
}
}
}
@@ -113,6 +113,32 @@ namespace Musify.Infrastructure.Tests.MassTransit
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_ProcessedWithoutOwnPicture_NeverRemovesThePresets()
{
var track = await SeedTrackAsync(
new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
},
new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.RemoveTrackProcessedAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
{
@@ -33,6 +33,24 @@ namespace Musify.Infrastructure.Tests.MassTransit
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_WithoutPicture_CompletesOnceTheAudioIsProcessed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(
new CreateTrackResourcesEvent(id, null, Guid.NewGuid(), "bucket", null, null, "audio-source", "audio", "audio-folder", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackAudioProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
{
@@ -88,6 +88,43 @@ namespace Musify.Infrastructure.Tests.MassTransit
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()
{