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(); } } }