737c423d9c
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.
210 lines
8.9 KiB
C#
210 lines
8.9 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|