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:
@@ -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
|
||||
|
||||
+13
-13
@@ -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);
|
||||
|
||||
|
||||
+6
-6
@@ -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]);
|
||||
|
||||
+6
-6
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-53
@@ -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);
|
||||
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
-105
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+1
-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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -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()
|
||||
};
|
||||
|
||||
-59
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user