diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj
index a24d3fc..90108ae 100644
--- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj
+++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj
@@ -42,6 +42,12 @@
+
+
+
+
+
+
diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs
new file mode 100644
index 0000000..faa9eda
--- /dev/null
+++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs
@@ -0,0 +1,156 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Musify.Api.DataTransferObjects.PlayLists;
+using Musify.Api.Tests.TestSupport;
+using Musify.Application.PlayLists.Responses;
+using Musify.Application.Shared;
+using Musify.Domain.ValueObjects;
+using Xunit;
+
+namespace Musify.Api.Tests.Endpoints;
+
+[Collection(ApiCollection.Name)]
+public sealed class PlayListEndpointsTests(ApiTestFixture fixture)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null)
+ {
+ var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync(
+ "/playlists",
+ new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility),
+ Ct);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!;
+ }
+
+ [Fact]
+ public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var stranger = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
+
+ var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct);
+ var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct);
+ var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
+
+ Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
+
+ var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var stranger = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
+
+ var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
+ var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
+
+ Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var stranger = await fixture.SeedUserAsync();
+ var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
+ var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
+
+ var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner);
+ var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner);
+
+ Assert.Equal([publicPlayList.Id], asStranger);
+ Assert.Contains(publicPlayList.Id, asOwner);
+ Assert.Contains(privatePlayList.Id, asOwner);
+ }
+
+ [Fact]
+ public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var stranger = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
+
+ var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
+ $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeletePlayList_NotTheOwner_ReturnsForbidden()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var stranger = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
+
+ var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task UpdatePlayList_Owner_RenamesIt()
+ {
+ var owner = await fixture.SeedUserAsync();
+ var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
+
+ var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
+ $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct);
+ Assert.Equal("Renamed", body!.Name);
+ }
+
+ [Fact]
+ public async Task GetPlayListById_NotAGuid_ReturnsNotFound()
+ {
+ var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem()
+ {
+ var owner = await fixture.SeedUserAsync();
+
+ var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync(
+ "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId)
+ {
+ var response = await client.GetAsync($"/playlists/users/{userId}", Ct);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct);
+ return page!.Items.Select(playList => playList.Id).ToList();
+ }
+}
diff --git a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs
new file mode 100644
index 0000000..248e062
--- /dev/null
+++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs
@@ -0,0 +1,22 @@
+using Musify.Infrastructure.Jobs;
+using Xunit;
+
+namespace Musify.Infrastructure.Tests.Jobs;
+
+public sealed class RecurringJobsRegistrarTests
+{
+ [Theory]
+ [InlineData(1, "*/1 * * * *")]
+ [InlineData(59, "*/1 * * * *")]
+ [InlineData(60, "*/1 * * * *")]
+ [InlineData(61, "*/2 * * * *")]
+ [InlineData(300, "*/5 * * * *")]
+ [InlineData(3599, "0 */1 * * *")]
+ [InlineData(3600, "0 */1 * * *")]
+ [InlineData(7200, "0 */2 * * *")]
+ [InlineData(0, "*/1 * * * *")]
+ public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected)
+ {
+ Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds));
+ }
+}
diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs
new file mode 100644
index 0000000..a2b7af5
--- /dev/null
+++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs
@@ -0,0 +1,199 @@
+using Musify.Application.Configuration;
+using Musify.Domain.Entities;
+using Musify.Domain.ValueObjects;
+using Musify.Infrastructure.MassTransit.RoutingSlip;
+using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
+using Musify.Infrastructure.Tests.TestSupport;
+using Xunit;
+
+namespace Musify.Infrastructure.Tests.MassTransit;
+
+[Collection(InfrastructureCollection.Name)]
+public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture)
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow()
+ {
+ var album = await SeedAlbumAsync(new EntityPictures
+ {
+ OriginalName = "original.webp", SmallName = "small.webp", MediumName = "medium.webp", LargeName = "large.webp"
+ });
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
+
+ Assert.Equal(
+ [
+ ActivityNames.MarkAlbumAsRemoving,
+ ActivityNames.RemoveAlbumOriginalPicture,
+ ActivityNames.RemoveAlbumSmallPicture,
+ ActivityNames.RemoveAlbumMediumPicture,
+ ActivityNames.RemoveAlbumLargePicture,
+ ActivityNames.DeleteAlbumFromDb,
+ ],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal()
+ {
+ var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp"));
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
+
+ Assert.Equal(
+ [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes()
+ {
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build();
+
+ Assert.Equal(
+ [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes()
+ {
+ var playList = await SeedPlayListAsync(pictures: null);
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
+
+ Assert.Equal(
+ [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete()
+ {
+ var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" });
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
+
+ Assert.Equal(
+ [
+ ActivityNames.MarkPlayListAsRemoving,
+ ActivityNames.RemovePlayListOriginalPicture,
+ ActivityNames.RemovePlayListSmallPicture,
+ ActivityNames.DeletePlayListFromDb,
+ ],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures()
+ {
+ var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" });
+ await using var database = fixture.CreateDatabase();
+
+ var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
+
+ Assert.Equal(
+ [
+ ActivityNames.MarkTrackAsRemoving,
+ ActivityNames.RemoveTrackOriginalPicture,
+ ActivityNames.RemoveTrackOriginalAudio,
+ ActivityNames.DeleteTrackFromDb,
+ ],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ [Fact]
+ public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
+ {
+ var track = await SeedTrackAsync(
+ new TrackPictures
+ {
+ OriginalName = "original.webp", SmallName = "s.webp", MediumName = "m.webp", LargeName = "l.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.RemoveTrackOriginalPicture,
+ ActivityNames.RemoveTrackSmallPicture,
+ ActivityNames.RemoveTrackMediumPicture,
+ ActivityNames.RemoveTrackLargePicture,
+ ActivityNames.RemoveTrackOriginalAudio,
+ ActivityNames.RemoveTrackProcessedAudio,
+ ActivityNames.DeleteTrackFromDb,
+ ],
+ slip.Itinerary.Select(activity => activity.Name));
+ }
+
+ private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) =>
+ new(
+ database,
+ new TrackConfiguration(),
+ new AlbumConfiguration(),
+ new PlayListConfiguration(),
+ new ApplicationStorageConfiguration { Bucket = "bucket" });
+
+ private async Task SeedUserAsync()
+ {
+ await using var database = fixture.CreateDatabase();
+ var name = $"delete-{Guid.NewGuid():N}"[..30];
+ var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
+ await database.Users.AddAsync(user, Ct);
+ await database.SaveChangesAsync(Ct);
+ return user;
+ }
+
+ private async Task SeedAlbumAsync(EntityPictures? pictures)
+ {
+ var user = await SeedUserAsync();
+ await using var database = fixture.CreateDatabase();
+ var title = $"album-{Guid.NewGuid():N}";
+ var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
+ await database.Albums.AddAsync(album, Ct);
+ await database.SaveChangesAsync(Ct);
+ return album;
+ }
+
+ private async Task SeedPlayListAsync(EntityPictures? pictures)
+ {
+ var user = await SeedUserAsync();
+ await using var database = fixture.CreateDatabase();
+ var name = $"pl-{Guid.NewGuid():N}"[..30];
+ var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
+ await database.PlayLists.AddAsync(playList, Ct);
+ await database.SaveChangesAsync(Ct);
+ return playList;
+ }
+
+ private async Task