feat: replace /archive with release-cardinal searchable browser (Phase 9 §8.H)
Retire the three-card overview for a search + medium + genre browser over all releases. Adds q/genre filter params to the api/release paged read path, mirroring the existing api/track/page TrackFilter pattern.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Query-shape tests for the release-cardinal browse path that backs the public /archive browser
|
||||
/// (Phase 9 §8.H). Exercises <see cref="ReleaseRepository.GetPagedByMediumAsync"/>: the medium
|
||||
/// narrowing, the genre filter, and the null/empty-filter passthrough.
|
||||
///
|
||||
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That covers
|
||||
/// the medium predicate, exact genre equality, and the count/ordering — every predicate except the
|
||||
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function
|
||||
/// with no in-memory translation, so the SearchText case is a Postgres integration test gated on a DSN
|
||||
/// (<see cref="GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive"/>). Without a
|
||||
/// configured database it is ignored rather than asserted against a provider that never runs the
|
||||
/// predicate — mirroring <see cref="TrackFilterQueryTests"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReleaseBrowseQueryTests
|
||||
{
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _context.Dispose();
|
||||
|
||||
private ReleaseRepository CreateRepository()
|
||||
=> new(_context, NullLogger<ReleaseRepository>.Instance);
|
||||
|
||||
private static ReleaseEntity Release(
|
||||
string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null)
|
||||
=> new()
|
||||
{
|
||||
Title = title,
|
||||
Artist = artist,
|
||||
Medium = medium,
|
||||
Genre = genre,
|
||||
};
|
||||
|
||||
private async Task SeedAsync(params ReleaseEntity[] releases)
|
||||
{
|
||||
_context.Releases.AddRange(releases);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static PagingParameters<ReleaseEntity> DefaultPaging()
|
||||
=> new() { Page = 1, PageSize = 20, OrderBy = r => r.Id, IsDescending = false };
|
||||
|
||||
// Medium filter narrows to a single medium: only releases of that medium are returned, and
|
||||
// TotalCount reflects the filtered set, not the table.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithMedium_ReturnsOnlyThatMedium()
|
||||
{
|
||||
await SeedAsync(
|
||||
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||
Release("Session One", "B", ReleaseMedium.Session),
|
||||
Release("Mix One", "C", ReleaseMedium.Mix),
|
||||
Release("Session Two", "D", ReleaseMedium.Session));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), ReleaseMedium.Session, filter: null, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(2));
|
||||
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Session One", "Session Two" }));
|
||||
}
|
||||
|
||||
// Clearing the medium filter (null) shows every medium — the all-releases archive default.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithNullMedium_ReturnsAllMedia()
|
||||
{
|
||||
await SeedAsync(
|
||||
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||
Release("Session One", "B", ReleaseMedium.Session),
|
||||
Release("Mix One", "C", ReleaseMedium.Mix));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(3));
|
||||
Assert.That(result.Items.Select(r => r.Title),
|
||||
Is.EquivalentTo(new[] { "Cut One", "Session One", "Mix One" }));
|
||||
}
|
||||
|
||||
// Genre filter narrows across all media: only releases of that exact genre are returned, regardless
|
||||
// of medium, and TotalCount reflects the filtered set.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithGenre_ReturnsOnlyThatGenreAcrossMedia()
|
||||
{
|
||||
await SeedAsync(
|
||||
Release("Cut One", "A", ReleaseMedium.Cut, genre: "Techno"),
|
||||
Release("Session One", "B", ReleaseMedium.Session, genre: "House"),
|
||||
Release("Mix One", "C", ReleaseMedium.Mix, genre: "Techno"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(2));
|
||||
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Cut One", "Mix One" }));
|
||||
}
|
||||
|
||||
// Medium and genre compose: the result is the intersection, narrowed both by medium and genre.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithMediumAndGenre_ComposesBothPredicates()
|
||||
{
|
||||
await SeedAsync(
|
||||
Release("Mix Techno", "A", ReleaseMedium.Mix, genre: "Techno"),
|
||||
Release("Mix House", "B", ReleaseMedium.Mix, genre: "House"),
|
||||
Release("Cut Techno", "C", ReleaseMedium.Cut, genre: "Techno"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), ReleaseMedium.Mix, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(1));
|
||||
Assert.That(result.Items.Single().Title, Is.EqualTo("Mix Techno"));
|
||||
}
|
||||
|
||||
// A null filter is a passthrough: same items and count as no filter at all. An all-null
|
||||
// ReleaseFilter is collapsed to null by the manager, so the repository sees null here.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithNullFilter_ReturnsAllReleases()
|
||||
{
|
||||
await SeedAsync(
|
||||
Release("Cut One", "A", ReleaseMedium.Cut),
|
||||
Release("Session One", "B", ReleaseMedium.Session),
|
||||
Release("Mix One", "C", ReleaseMedium.Mix));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
// Soft-deleted releases never surface in the browse list, with or without a filter.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_ExcludesSoftDeletedReleases()
|
||||
{
|
||||
var deleted = Release("Gone", "A", ReleaseMedium.Cut, genre: "Techno");
|
||||
deleted.IsDeleted = true;
|
||||
await SeedAsync(
|
||||
Release("Live", "B", ReleaseMedium.Cut, genre: "Techno"),
|
||||
deleted);
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
|
||||
|
||||
Assert.That(result.TotalCount, Is.EqualTo(1));
|
||||
Assert.That(result.Items.Single().Title, Is.EqualTo("Live"));
|
||||
}
|
||||
|
||||
// Free-text search across Title and Artist, case-insensitive. EF.Functions.ILike is Npgsql-only and
|
||||
// does not translate on the in-memory provider, so this runs only against a real Postgres database
|
||||
// supplied via the DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than
|
||||
// asserted against a provider that cannot execute the predicate.
|
||||
[Test]
|
||||
public async Task GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive()
|
||||
{
|
||||
var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG");
|
||||
if (string.IsNullOrWhiteSpace(dsn))
|
||||
Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test.");
|
||||
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseNpgsql(dsn)
|
||||
.Options;
|
||||
await using var pg = new DeepDrftContext(options);
|
||||
await pg.Database.EnsureCreatedAsync();
|
||||
|
||||
try
|
||||
{
|
||||
pg.Releases.AddRange(
|
||||
Release("Nightfall", "jazzmin", ReleaseMedium.Session),
|
||||
Release("All JAZZ Hands", "Brick", ReleaseMedium.Cut),
|
||||
Release("Silence", "Nobody", ReleaseMedium.Mix));
|
||||
await pg.SaveChangesAsync();
|
||||
|
||||
var repo = new ReleaseRepository(pg, NullLogger<ReleaseRepository>.Instance);
|
||||
var result = await repo.GetPagedByMediumAsync(
|
||||
DefaultPaging(), medium: null, new ReleaseFilter { SearchText = "jazz" }, CancellationToken.None);
|
||||
|
||||
Assert.That(result.Items.Select(r => r.Title),
|
||||
Is.EquivalentTo(new[] { "Nightfall", "All JAZZ Hands" }),
|
||||
"ILike matches 'jazz' case-insensitively in the release Title or Artist");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pg.Database.EnsureDeletedAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user