Remove YouTube integration from backend and deployment config

Drops the YouTube-backed second catalog source entirely: the
Local/External split on Track/Album collapses into single concrete
entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the
YouTube service/download pipeline (yt-dlp, MassTransit consumer,
routing slip) are deleted, and mixes now source discovery only from
local listening history. Includes the RemoveYouTubeIntegration EF
Core migration and matching Worker/deploy/compose cleanup (no more
yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp
config sections).
This commit is contained in:
2026-09-15 21:17:03 +02:00
parent 5696683859
commit 31032bd8e8
137 changed files with 1933 additions and 3969 deletions
@@ -25,8 +25,6 @@ namespace Musify.Api.Tests.TestSupport
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async ValueTask InitializeAsync()
@@ -88,7 +86,6 @@ namespace Musify.Api.Tests.TestSupport
{
services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
services
@@ -15,8 +15,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_FirstTrack_AddsItAtNumberOne()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
@@ -30,7 +30,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
@@ -43,8 +43,8 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
@@ -56,7 +56,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -69,8 +69,8 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.UserAlbum(owner.Id);
var foreignTrack = TestEntities.LocalTrack(otherOwner);
var album = TestEntities.Album(owner.Id);
var foreignTrack = TestEntities.Track(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
@@ -82,8 +82,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
@@ -96,9 +96,9 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var firstTrack = TestEntities.LocalTrack(owner, "First");
var secondTrack = TestEntities.LocalTrack(owner, "Second");
var album = TestEntities.Album(owner.Id);
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
@@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Albums
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
@@ -13,13 +13,13 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_Owner_RemovesAlbum()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
[Fact]
@@ -35,13 +35,13 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
}
}
@@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
@@ -17,7 +17,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken);
@@ -14,9 +14,9 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
var album = TestEntities.Album(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
@@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs"));
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
@@ -1,99 +1,44 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private GetAlbumsQueryHandler CreateHandler() =>
new(Database, youTubeMusicService, NoOpLogger<GetAlbumsQueryHandler>());
private GetAlbumsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged_AndDoesNotCallYouTube()
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.NotNull(item.Album));
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Handle_TitleFilter_ReturnsMatchingLocalAlbums_FollowedByYouTubeResults()
public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
var album = new YouTubeAlbumResult("album-1", "Rock Classics Reissue", "Various Artists", "thumb.jpg", 2020, false, false, []);
youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any<CancellationToken>())
.Returns(new YouTubeAlbumSearchResult([album], "next-token"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.Items.Count);
Assert.NotNull(result.Value.Items[0].Album);
Assert.Equal("Rock Classics", result.Value.Items[0].Album!.Title);
Assert.Null(result.Value.Items[1].Album);
Assert.NotNull(result.Value.Items[1].YouTubeAlbum);
Assert.Equal("album-1", result.Value.Items[1].YouTubeAlbum!.AlbumId);
Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken);
Assert.False(result.Value.YoutubeUnavailable);
}
[Fact]
public async Task Handle_TitleFilter_SkipsYouTubeResultAlreadyMaterializedLocally()
{
var externalAlbum = TestEntities.ExternalAlbum(externalId: "album-1", title: "Rock Classics", source: TrackSource.YouTube);
await SeedAsync(externalAlbum);
var album = new YouTubeAlbumResult("album-1", "Rock Classics", "Various Artists", "thumb.jpg", 2020, false, false, []);
youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any<CancellationToken>())
.Returns(new YouTubeAlbumSearchResult([album], string.Empty));
await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.NotNull(item.Album);
Assert.Null(item.YouTubeAlbum);
}
[Fact]
public async Task Handle_TitleFilter_YouTubeUnavailable_StillReturnsLocalResults()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"));
youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any<CancellationToken>())
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.NotNull(item.Album);
Assert.True(result.Value.YoutubeUnavailable);
Assert.Null(result.Value.NextYoutubeContinuationToken);
Assert.Equal("Rock Classics", item.Album.Title);
}
[Fact]
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
@@ -13,10 +13,10 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
{
var owner = TestEntities.User();
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
var olderAlbum = TestEntities.Album(owner.Id, "Older");
var newerAlbum = TestEntities.Album(owner.Id, "Newer");
var olderTrack = TestEntities.Track(owner, "In older album");
var newerTrack = TestEntities.Track(owner, "In newer album");
await SeedAsync(
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
@@ -46,8 +46,8 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
{
var owner = TestEntities.User();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList();
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
@@ -16,10 +16,10 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var first = TestEntities.LocalTrack(owner, "First");
var second = TestEntities.LocalTrack(owner, "Second");
var third = TestEntities.LocalTrack(owner, "Third");
var album = TestEntities.Album(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, album, first, second, third,
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
@@ -50,7 +50,7 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -62,7 +62,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -26,7 +26,7 @@ namespace Musify.Application.Tests.Albums
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null);
@@ -38,7 +38,7 @@ namespace Musify.Application.Tests.Albums
Assert.Equal("New description", result.Value.Description);
Assert.Equal(2025, result.Value.ReleaseYear);
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
}
@@ -51,7 +51,7 @@ namespace Musify.Application.Tests.Albums
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title");
var album = TestEntities.Album(owner.Id, title: "Old Title");
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
@@ -60,7 +60,7 @@ namespace Musify.Application.Tests.Albums
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
@@ -81,7 +81,7 @@ namespace Musify.Application.Tests.Albums
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null);
@@ -13,8 +13,8 @@ namespace Musify.Application.Tests.Likes
public async Task Handle_MultipleLikes_ReturnsMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.LocalTrack(owner, "Older like");
var newer = TestEntities.LocalTrack(owner, "Newer like");
var older = TestEntities.Track(owner, "Older like");
var newer = TestEntities.Track(owner, "Newer like");
await SeedAsync(
owner, older, newer,
new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) },
@@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Likes
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
@@ -1,40 +1,25 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Likes;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Likes
{
public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private ToggleTrackLikeCommandHandler CreateHandler()
{
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
return new ToggleTrackLikeCommandHandler(Database, provisioner, NoOpLogger<ToggleTrackLikeCommandHandler>());
}
private static YouTubeSongResult BuildSong(string videoId) => new(
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
[new YouTubeArtistRef("artist-id", "Song Artist")]);
private ToggleTrackLikeCommandHandler CreateHandler() =>
new(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
[Fact]
public async Task Handle_LocalTrackNotLiked_LikesIt()
public async Task Handle_TrackNotLiked_LikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken);
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
@@ -42,14 +27,14 @@ namespace Musify.Application.Tests.Likes
}
[Fact]
public async Task Handle_LocalTrackAlreadyLiked_UnlikesIt()
public async Task Handle_TrackAlreadyLiked_UnlikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken);
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.False(result.Value);
@@ -63,41 +48,9 @@ namespace Musify.Application.Tests.Likes
await SeedAsync(owner);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid(), null, null), TestContext.Current.CancellationToken);
new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UnknownExternalTrack_ProvisionsAndLikesIt()
{
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "new-video"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
Assert.True(await Database.TrackLikes.AnyAsync(like => like.TrackId == track.Id, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_ExistingExternalTrack_LikesItWithoutReprovisioning()
{
var owner = TestEntities.User();
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "abc123"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
await youTubeMusicService.DidNotReceive().GetSongAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
}
@@ -1,24 +1,18 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Mixes
{
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private GenerateMixesForUserCommandHandler CreateHandler() => new(
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
[Fact]
public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
public async Task Handle_NoTracks_DoesNotCreateAnyMix()
{
var user = TestEntities.User();
await SeedAsync(user);
@@ -30,75 +24,53 @@ namespace Musify.Application.Tests.Mixes
}
[Fact]
public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
new UserHasTrack { UserId = user.Id, TrackId = track.Id },
TestEntities.ListeningHistory(user.Id, track.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Single(mixes);
Assert.Equal("Tu mezcla diaria", mixes[0].Title);
}
[Fact]
public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes()
{
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new YouTubeSearchResult(
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )],
ContinuationToken: string.Empty));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var user = TestEntities.User();
var artist = TestEntities.Artist("Seed Artist");
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
var listenedTrack = TestEntities.Track(user, "Listened Track");
var unheardTrack = TestEntities.Track(user, "Unheard Track");
var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item");
var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id);
await SeedAsync(
user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem,
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
user, listenedTrack, unheardTrack, staleMix, staleItem,
new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id },
new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id },
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(mixes);
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria");
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly()
{
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
var user = TestEntities.User();
var artist = TestEntities.Artist("Seed Artist");
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
await SeedAsync(
user, artist, listenedTrack, otherTrackByArtist,
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(items);
Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source));
var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento");
var discoveryItems = await Database.MixItems
.Where(item => item.MixId == discoveryMix.Id)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
}
}
}
@@ -13,10 +13,12 @@ namespace Musify.Application.Tests.Mixes
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
{
var owner = TestEntities.User();
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var mix = TestEntities.Mix(owner.Id, "Discovery");
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
await SeedAsync(owner, mix, second, first);
var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1);
var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0);
await SeedAsync(owner, firstTrack, secondTrack, mix, second, first);
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
@@ -16,7 +16,7 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track);
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
@@ -31,8 +31,8 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
var newTrack = TestEntities.LocalTrack(owner, "New");
var existingTrack = TestEntities.Track(owner, "Existing");
var newTrack = TestEntities.Track(owner, "New");
await SeedAsync(
owner, playList, existingTrack, newTrack,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
@@ -82,7 +82,7 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
@@ -1,105 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists
{
public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
{
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
return new AddYouTubeTrackToPlayListCommandHandler(
Database,
eventBus,
youTubeMusicService,
provisioner,
TestConfigurations.Storage(),
TestConfigurations.Track(),
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
}
private static YouTubeSongResult BuildSong(string videoId) => new(
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
[new YouTubeArtistRef("artist-id", "Song Artist")]);
[Fact]
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload()
{
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Song Title", result.Value.Title);
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
Assert.True(track.Audio.DownloadRequested);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
[Fact]
public async Task Handle_TrackAlreadyLinked_ReturnsConflict()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
}
}
@@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
@@ -15,8 +15,8 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, playList, second, first,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
@@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
@@ -24,7 +24,7 @@ namespace Musify.Application.Tests.Services
{
ticketService.IssueTicket(7, Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("signed-token", 120));
var owner = TestEntities.User(7);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken);
@@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Services
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var owner = TestEntities.User(1);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
@@ -58,7 +58,7 @@ namespace Musify.Application.Tests.Services
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var owner = TestEntities.User(1);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true))
@@ -73,7 +73,7 @@ namespace Musify.Application.Tests.Services
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var owner = TestEntities.User(1);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
@@ -87,7 +87,7 @@ namespace Musify.Application.Tests.Services
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var owner = TestEntities.User(1);
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
@@ -1,104 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Services
{
public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private YouTubeTrackProvisioner CreateProvisioner() =>
new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
[Fact]
public async Task FindAsync_UnknownVideo_ReturnsNull()
{
var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken);
Assert.Null(track);
}
[Fact]
public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService()
{
var existing = TestEntities.ExternalTrack(externalId: "known-video");
await SeedAsync(existing);
var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(existing.Id, result.Value.Track.Id);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
{
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
new YouTubeSongResult(
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("New Song", result.Value.Track.Title);
Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
{
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
[Fact]
public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
{
var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
[new YouTubeArtistRef(null, "Album Artist")]),
Description: "desc",
TotalDurationSeconds: 400,
Tracks:
[
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
]);
await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken));
Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
{
var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
Description: "desc",
TotalDurationSeconds: 200,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
var provisioner = CreateProvisioner();
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -1,6 +1,5 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Shared
@@ -8,44 +7,16 @@ namespace Musify.Application.Tests.Shared
public sealed class TrackApplicationResponseTests
{
[Fact]
public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
public void FromEntity_UsesOwnerNameAsArtist()
{
var owner = TestEntities.User(1, "The Owner");
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
var response = Assert.IsType<LocalTrackApplicationResponse>(TrackApplicationResponse.FromEntity(track, listensCount: 3));
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount);
}
[Fact]
public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder()
{
var track = TestEntities.ExternalTrack();
var second = TestEntities.Artist("Second Artist", externalId: "second");
var first = TestEntities.Artist("First Artist", externalId: "first");
track.TrackArtists =
[
new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track },
new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track }
];
var response = Assert.IsType<ExternalTrackApplicationResponse>(TrackApplicationResponse.FromEntity(track, listensCount: 0));
Assert.Equal("First Artist, Second Artist", response.Artist);
Assert.Equal(track.ExternalId, response.ExternalId);
}
[Fact]
public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist()
{
var track = TestEntities.ExternalTrack();
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.Null(response.Artist);
}
}
}
@@ -39,59 +39,24 @@ namespace Musify.Application.Tests.TestSupport
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks");
modelBuilder.Entity<LocalTrack>()
modelBuilder.Entity<Track>()
.HasOne(track => track.Owner)
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks");
modelBuilder.Entity<ExternalTrack>()
.HasIndex(track => new { track.Source, track.ExternalId })
.IsUnique();
modelBuilder.Entity<TrackArtist>()
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
modelBuilder.Entity<TrackArtist>()
.HasOne(trackArtist => trackArtist.Track)
.WithMany(track => track.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackArtist>()
.HasOne(trackArtist => trackArtist.Artist)
.WithMany(artist => artist.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Artist>()
.HasIndex(artist => artist.ExternalId)
.IsUnique();
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
modelBuilder.Entity<Album>()
.HasOne(album => album.Owner)
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums");
modelBuilder.Entity<ExternalAlbum>()
.HasIndex(album => new { album.Source, album.ExternalId })
.IsUnique();
modelBuilder.Entity<AlbumHasTrack>()
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
@@ -121,7 +86,6 @@ namespace Musify.Application.Tests.TestSupport
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>()
@@ -154,22 +118,10 @@ namespace Musify.Application.Tests.TestSupport
public DbSet<Album> Albums => Set<Album>();
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
public DbSet<Artist> Artists => Set<Artist>();
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
@@ -49,7 +49,7 @@ namespace Musify.Application.Tests.TestSupport
TranscodeStatus = ProcessingStatus.Pending
};
public static LocalTrack LocalTrack(
public static Track Track(
User owner,
string title = "Test Track",
double durationSeconds = 180,
@@ -68,36 +68,6 @@ namespace Musify.Application.Tests.TestSupport
Audio = audio ?? ProcessedAudio()
};
public static ExternalTrack ExternalTrack(
string externalId = "video-id",
string title = "Test External Track",
double durationSeconds = 180,
TrackSource source = TrackSource.YouTube,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
Source = source,
ExternalId = externalId,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
};
public static Artist Artist(
string name = "Test Artist",
string externalId = "artist-id") =>
new()
{
Name = name,
NormalizedName = name.ToUpperInvariant(),
ExternalId = externalId
};
public static PlayListPictures PlayListPictures() => new()
{
OriginalName = "original.webp",
@@ -132,7 +102,7 @@ namespace Musify.Application.Tests.TestSupport
LargeName = "large.webp"
};
public static UserAlbum UserAlbum(
public static Album Album(
long ownerUserId,
string title = "Test Album",
string? description = null,
@@ -148,20 +118,6 @@ namespace Musify.Application.Tests.TestSupport
Pictures = pictures ?? AlbumPictures()
};
public static ExternalAlbum ExternalAlbum(
string externalId = "album-id",
string title = "Test External Album",
TrackSource source = TrackSource.YouTube,
string? thumbnailUrl = null) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Source = source,
ExternalId = externalId,
ThumbnailUrl = thumbnailUrl
};
public static UploadIntent UploadIntent(
long userId,
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
@@ -200,25 +156,13 @@ namespace Musify.Application.Tests.TestSupport
public static MixItem MixItem(
Guid mixId,
int position = 0,
MixItemSource source = MixItemSource.Musify,
Guid? trackId = null,
string? videoId = null,
string title = "Test Mix Item",
string? artist = "Test Artist",
double durationSeconds = 180,
bool isExplicit = false) =>
Guid trackId,
int position = 0) =>
new()
{
MixId = mixId,
Position = position,
Source = source,
TrackId = trackId,
VideoId = videoId,
Title = title,
Artist = artist,
DurationSeconds = durationSeconds,
IsExplicit = isExplicit
TrackId = trackId
};
public static ListeningHistory ListeningHistory(
@@ -48,11 +48,11 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
var localResponse = Assert.IsType<LocalTrackApplicationResponse>(result.Value);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(user.Id, localResponse.OwnerUserId);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
}
@@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
@@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Tracks
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
@@ -51,22 +51,11 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
[Fact]
public async Task Handle_ExternalTrack_ReturnsUnauthorized()
{
var externalTrack = TestEntities.ExternalTrack();
await SeedAsync(externalTrack);
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
[Fact]
public async Task Handle_AudioStillProcessing_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
@@ -12,8 +12,8 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.LocalTrack(owner, "Older listen");
var newer = TestEntities.LocalTrack(owner, "Newer listen");
var older = TestEntities.Track(owner, "Older listen");
var newer = TestEntities.Track(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
@@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
{
var owner = TestEntities.User(1, "artist-name");
var track = TestEntities.LocalTrack(owner, "My Song");
var track = TestEntities.Track(owner, "My Song");
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
@@ -22,7 +22,7 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
Assert.Equal(owner.Name, result.Value.Artist);
var localResponse = Assert.IsType<LocalTrackApplicationResponse>(result.Value);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(owner.Id, localResponse.OwnerUserId);
}
@@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
@@ -41,7 +41,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
@@ -32,7 +32,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
@@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
@@ -71,7 +71,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
@@ -85,7 +85,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
@@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Tracks
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var mine = TestEntities.LocalTrack(owner, "Mine");
var theirs = TestEntities.LocalTrack(other, "Theirs");
var mine = TestEntities.Track(owner, "Mine");
var theirs = TestEntities.Track(other, "Theirs");
await SeedAsync(
owner, other, mine, theirs,
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
@@ -34,7 +34,7 @@ namespace Musify.Application.Tests.Tracks
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
var track = TestEntities.Track(owner);
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken);
@@ -1,26 +1,19 @@
using ErrorOr;
using Musify.Application.Contracts;
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 GetTracksQueryHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private GetTracksQueryHandler CreateHandler() =>
new(Database, youTubeMusicService, NoOpLogger<GetTracksQueryHandler>());
private GetTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube()
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
{
var owner = TestEntities.User();
var trackA = TestEntities.LocalTrack(owner, "Alpha");
var trackB = TestEntities.LocalTrack(owner, "Beta");
var trackA = TestEntities.Track(owner, "Alpha");
var trackB = TestEntities.Track(owner, "Beta");
await SeedAsync(
owner, trackA, trackB,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id },
@@ -31,81 +24,24 @@ namespace Musify.Application.Tests.Tracks
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.Equal(2, result.Value.Items.Count);
Assert.All(result.Value.Items, item => Assert.NotNull(item.Track));
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults()
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
{
var owner = TestEntities.User();
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
var other = TestEntities.LocalTrack(owner, "Imagine");
var match = TestEntities.Track(owner, "Bohemian Rhapsody");
var other = TestEntities.Track(owner, "Imagine");
await SeedAsync(
owner, match, other,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody (Live)", "Queen", "A Night at the Opera",
354, "thumb.jpg", false, []);
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
.Returns(new YouTubeSearchResult([song], "next-token"));
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.Items.Count);
Assert.NotNull(result.Value.Items[0].Track);
Assert.Equal("Bohemian Rhapsody", result.Value.Items[0].Track!.Title);
Assert.Null(result.Value.Items[1].Track);
Assert.NotNull(result.Value.Items[1].YouTubeSong);
Assert.Equal("video-1", result.Value.Items[1].YouTubeSong!.VideoId);
Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken);
Assert.False(result.Value.YoutubeUnavailable);
}
[Fact]
public async Task Handle_NameFilter_SkipsYouTubeResultAlreadyProvisionedLocally()
{
var owner = TestEntities.User();
var provisioned = TestEntities.ExternalTrack(externalId: "video-1", title: "Bohemian Rhapsody", source: TrackSource.YouTube);
await SeedAsync(
owner, provisioned,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = provisioned.Id });
var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody", "Queen", "A Night at the Opera",
354, "thumb.jpg", false, []);
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
.Returns(new YouTubeSearchResult([song], string.Empty));
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.NotNull(item.Track);
Assert.Null(item.YouTubeSong);
}
[Fact]
public async Task Handle_NameFilter_YouTubeUnavailable_StillReturnsLocalResults()
{
var owner = TestEntities.User();
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
await SeedAsync(
owner, match,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id });
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.NotNull(item.Track);
Assert.True(result.Value.YoutubeUnavailable);
Assert.Null(result.Value.NextYoutubeContinuationToken);
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
}
}
}
@@ -25,8 +25,8 @@ namespace Musify.Application.Tests.Users
public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds()
{
var user = TestEntities.User();
var trackA = TestEntities.LocalTrack(user, "Track A", durationSeconds: 200);
var trackB = TestEntities.LocalTrack(user, "Track B", durationSeconds: 100);
var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200);
var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100);
await SeedAsync(
user, trackA, trackB,
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)),
@@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Users
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
@@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Users
public async Task Handle_GapBeforeToday_StopsStreakAtGap()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
@@ -74,7 +74,7 @@ namespace Musify.Application.Tests.Users
public async Task Handle_NoListenToday_StreakIsZero()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
@@ -1,62 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.YouTube;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.YouTube
{
public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private GetYouTubeAlbumQueryHandler CreateHandler()
{
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>());
}
private static YouTubeAlbumDetail BuildDetail() => new(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
Description: "An album",
TotalDurationSeconds: 600,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
[Fact]
public async Task Handle_EmptyAlbumId_ReturnsValidationError()
{
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail()
{
var detail = BuildDetail();
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Same(detail, result.Value);
Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_ServiceFails_ReturnsError()
{
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>())
.Returns(Error.NotFound(description: "Album not found"));
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
}
}
@@ -1,125 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.YouTube;
using Musify.Application.YouTube.Responses;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.YouTube
{
public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
{
playback ??= TestConfigurations.Playback();
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
var issuer = new TrackStreamIssuer(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
return new ResolveYouTubeTrackStreamCommandHandler(
Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
}
[Fact]
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var user = TestEntities.User(1);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(user, track);
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
Assert.Equal("token", result.Value.Ticket);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory()
{
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
await SeedAsync(TestEntities.User(42));
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
Assert.NotNull(result.Value.TrackId);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(42, history.UserId);
}
[Fact]
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
{
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Handle_AnonymousUser_AlreadyProcessedTrack_IssuesServerStreamWithoutRecordingHistory()
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(track);
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_AnonymousUser_FragmentCapConfigured_UnprovisionedTrack_ReturnsConflictWithoutCallingYouTube()
{
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30));
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("not-provisioned-yet", UserId: null), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Handle_AnonymousUser_NoFragmentCap_UnprovisionedTrack_FallsBackToDirectYouTubeStream()
{
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 0));
var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("new-video", UserId: null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -10,7 +10,7 @@ namespace Musify.Infrastructure.Tests.Persistence
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
{
[Fact]
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres()
public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres()
{
await using var database = fixture.CreateDatabase();
@@ -19,7 +19,7 @@ namespace Musify.Infrastructure.Tests.Persistence
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
var trackTitle = "Round Trip Track";
var track = new LocalTrack
var track = new Track
{
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
@@ -29,32 +29,16 @@ namespace Musify.Infrastructure.Tests.Persistence
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
};
await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken);
await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
Assert.IsType<LocalTrack>(stored);
Assert.Equal(trackTitle, stored.Title);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal("song.mp3", stored.Audio.OriginalName);
Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId);
}
[Fact]
public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates()
{
await using var database = fixture.CreateDatabase();
var videoId = $"dup-{Guid.NewGuid():N}";
await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var second = fixture.CreateDatabase();
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(TestContext.Current.CancellationToken));
Assert.Equal(user.Id, stored.OwnerUserId);
}
[Fact]
@@ -65,33 +49,32 @@ namespace Musify.Infrastructure.Tests.Persistence
var userName = "cascade-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var albumTitle = "Cascade Album";
var album = new UserAlbum { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id };
var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}");
var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id };
var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}");
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await database.AddRangeAsync(user, album, track, link);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var deleter = fixture.CreateDatabase();
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.UserAlbums.Remove(albumToDelete);
var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.Albums.Remove(albumToDelete);
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
}
private static ExternalTrack BuildExternalTrack(string externalId)
private static Track BuildTrack(User owner, string title)
{
var title = "External";
return new ExternalTrack
return new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 100,
Source = TrackSource.YouTube,
ExternalId = externalId,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
@@ -1,59 +0,0 @@
using ErrorOr;
using Musify.Infrastructure.Services;
using Xunit;
namespace Musify.Infrastructure.Tests.Services
{
public sealed class DisabledYouTubeMusicServiceTests
{
private readonly DisabledYouTubeMusicService service = new();
[Fact]
public async Task SearchSongsAsync_ReturnsForbidden()
{
var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task SearchAlbumsAsync_ReturnsForbidden()
{
var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task GetAlbumAsync_ReturnsForbidden()
{
var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task GetAudioStreamAsync_ReturnsForbidden()
{
var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task GetSongAsync_ReturnsForbidden()
{
var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public void ResolveArtworkUrl_ReturnsTheUrlUnchanged()
{
var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg");
Assert.Equal("https://img.example/thumb.jpg", result);
}
}
}