Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles, and the upload form kept its own copy of the genre list and compatibility rules. Both now come from the backend. Backend: - GET /genres returns genres with at least one track and their counts. - GET /genres/available returns every assignable genre with the genres it cannot be combined with, so the rules live only in GenreCompatibility. - GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values). web-player: - Discover tiles are built from /genres; each opens /explore?genre=X, a song list filtered by that tag. Unknown genres fall back to a label derived from their name. - The upload form loads /genres/available, disables incompatible genres as you pick, and the server validates before reserving upload URLs. - lib/data/trackGenres.ts is removed; its helpers merge into lib/data/genres.ts. - Regenerate the typed API client.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private async Task<string> SeedTrackAsync(Genre genre)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
|
||||
var title = $"Genre track {Guid.NewGuid():N}";
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
|
||||
};
|
||||
foreach (var tag in track.Tags)
|
||||
tag.TrackId = track.Id;
|
||||
database.Users.Add(owner);
|
||||
database.Tracks.Add(track);
|
||||
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return title;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGenres_Anonymous_ListsGenresInUse()
|
||||
{
|
||||
await SeedTrackAsync(Genre.Funk);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
|
||||
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Nope")]
|
||||
[InlineData("999")]
|
||||
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Blues);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_FiltersByTag()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Ambient);
|
||||
await SeedTrackAsync(Genre.Techno);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetAvailableGenresQueryHandlerTests
|
||||
{
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
|
||||
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryGenre()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListsIncompatibleGenresSymmetrically()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
var metal = result.Single(r => r.Genre == Genre.Metal);
|
||||
var lofi = result.Single(r => r.Genre == Genre.Lofi);
|
||||
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
|
||||
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
|
||||
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetGenresQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTracks_ReturnsEmpty()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
|
||||
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rockA, rockB, jazz,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
|
||||
result.Select(r => (r.Genre, r.TrackCount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var other = TestEntities.User(id: 2, name: "other-user");
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
|
||||
await SeedAsync(
|
||||
owner, other, track,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, Assert.Single(result).TrackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rock, jazz,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Loud", item.Track.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user