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;
///
/// Query-shape tests for the release-cardinal browse path that backs the public /archive browser
/// (Phase 9 ยง8.H). Exercises : 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 EF.Functions.ILike, an Npgsql-only relational function
/// with no in-memory translation, so the SearchText case is a Postgres integration test gated on a DSN
/// (). Without a
/// configured database it is ignored rather than asserted against a provider that never runs the
/// predicate โ mirroring .
///
[TestFixture]
public class ReleaseBrowseQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private ReleaseRepository CreateRepository()
=> new(_context, NullLogger.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 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()
.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.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();
}
}
}