using Data.Data.Repositories; using DeepDrftData.Data; using DeepDrftData.Repositories; using DeepDrftModels.DTOs; using DeepDrftModels.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Models.Common; namespace DeepDrftTests; /// /// Query-shape tests for the filter and distinct-browse repository methods, updated for the /// Phase 8 §8.0 normalized schema: release-cardinal data (Artist, Album→Title, Genre, ImagePath) /// lives on , reached through the nullable Release navigation. Tracks /// link via ReleaseId; loose tracks have a null release. /// /// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That /// covers exact-match equality through the navigation, null passthrough, GroupBy/Count, and /// ordering — every predicate in 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 (see SearchText_*). It is ignored when no test database is configured rather /// than asserted against a provider that never runs the predicate. /// [TestFixture] public class TrackFilterQueryTests { 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 TrackRepository CreateRepository() => new(_context, NullLogger>.Instance); private static ReleaseEntity Release( string title, string artist, string? genre = null, string? image = null) => new() { EntryKey = Guid.NewGuid().ToString("N"), Title = title, Artist = artist, Genre = genre, ImagePath = image, }; // A track linked to the given release (or loose when release is null). private static TrackEntity Track(string name, ReleaseEntity? release = null) => new() { EntryKey = Guid.NewGuid().ToString("N"), TrackName = name, Release = release, }; private async Task SeedAsync(params TrackEntity[] tracks) { _context.Tracks.AddRange(tracks); await _context.SaveChangesAsync(); } private static PagingParameters DefaultPaging() => new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false }; // Case 2 — exact album match: returns only rows whose linked release Title equals the filter // value, and TotalCount reflects the filtered set, not the table. [Test] public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum() { var blue = Release("Blue", "A"); var red = Release("Red", "C"); await SeedAsync( Track("One", blue), Track("Two", blue), Track("Three", red), Track("Four")); var repo = CreateRepository(); var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" }); Assert.That(result.TotalCount, Is.EqualTo(2)); Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" })); } // Case 2b — exact genre match composes the same way as album, through the release join. [Test] public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre() { await SeedAsync( Track("One", Release("A1", "A", genre: "Techno")), Track("Two", Release("A2", "B", genre: "House")), Track("Three", Release("A3", "C", genre: "Techno"))); var repo = CreateRepository(); var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" }); Assert.That(result.TotalCount, Is.EqualTo(2)); Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Three" })); } // Case 3 — null filter is a passthrough: same items and count as the unfiltered base GetPagedAsync. [Test] public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery() { await SeedAsync( Track("One", Release("Blue", "A")), Track("Two", Release("Red", "B")), Track("Three")); var repo = CreateRepository(); var baseline = await repo.GetPagedAsync(DefaultPaging()); var filtered = await repo.GetPagedFilteredAsync(DefaultPaging(), filter: null); Assert.That(filtered.TotalCount, Is.EqualTo(baseline.TotalCount)); Assert.That( filtered.Items.Select(t => t.Id), Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection); } // Case 4 — releases: returns every non-deleted release, title-ascending. Replaces the old // distinct-albums grouping; one row per release rather than per distinct album string. [Test] public async Task GetReleasesAsync_ReturnsAllReleasesTitleAscending() { await SeedAsync( Track("One", Release("Zephyr", "A", image: "cover-z")), Track("Two", Release("Aria", "B", image: "cover-a")), Track("Three")); var repo = CreateRepository(); var releases = await repo.GetReleasesAsync(); Assert.That(releases.Select(r => r.Title), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection, "releases sort by title ascending; the loose track contributes none"); Assert.That(releases.Single(r => r.Title == "Zephyr").ImagePath, Is.EqualTo("cover-z")); Assert.That(releases.Single(r => r.Title == "Aria").ImagePath, Is.EqualTo("cover-a")); } // Case 4b — per-release track counts: keyed by ReleaseId, counting only non-deleted tracks. // Loose tracks (null ReleaseId) contribute no entry. Backs ReleaseDto.TrackCount. [Test] public async Task GetTrackCountsByReleaseAsync_CountsTracksPerRelease() { var zephyr = Release("Zephyr", "A"); var aria = Release("Aria", "B"); await SeedAsync( Track("One", zephyr), Track("Two", zephyr), Track("Three", aria), Track("Four")); var repo = CreateRepository(); var counts = await repo.GetTrackCountsByReleaseAsync(); Assert.That(counts[zephyr.Id], Is.EqualTo(2)); Assert.That(counts[aria.Id], Is.EqualTo(1)); Assert.That(counts.Count, Is.EqualTo(2), "the loose track contributes no release key"); } // Case 5 — distinct genres: sourced from the release join, excludes releases with null genre, // counts tracks per genre, ordered genre ascending. [Test] public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull() { await SeedAsync( Track("One", Release("A1", "A", genre: "Techno")), Track("Two", Release("A2", "B", genre: "Ambient")), Track("Three", Release("A3", "C", genre: "Techno")), Track("Four", Release("A4", "D"))); var repo = CreateRepository(); var genres = await repo.GetDistinctGenresAsync(); Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection, "genres sort ascending and the null-genre release is excluded"); Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2)); Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1)); } // Case 6 — find-or-create resolution: an existing (title, artist) returns the stored row, no // duplicate insert. Exercises the natural-key lookup that backs the upload FK resolution. [Test] public async Task GetReleaseByTitleAndArtistAsync_ReturnsExistingMatch() { var blue = Release("Blue", "Artist A"); await SeedAsync(Track("One", blue)); var repo = CreateRepository(); var found = await repo.GetReleaseByTitleAndArtistAsync("Blue", "Artist A"); Assert.That(found, Is.Not.Null); Assert.That(found!.Id, Is.EqualTo(blue.Id)); } // Case 6b — no match returns null so the manager creates a fresh release. [Test] public async Task GetReleaseByTitleAndArtistAsync_ReturnsNullWhenNoMatch() { await SeedAsync(Track("One", Release("Blue", "Artist A"))); var repo = CreateRepository(); var found = await repo.GetReleaseByTitleAndArtistAsync("Red", "Artist A"); Assert.That(found, Is.Null); } // Case 1 — free-text search across TrackName plus the joined release Artist/Title, // 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 GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive() { 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.Tracks.AddRange( Track("Jazz Odyssey", Release("Smell the Glove", "Spinal Tap")), Track("Quiet Storm", Release("Nightfall", "jazzmin")), Track("Loud Noises", Release("All JAZZ Hands", "Brick")), Track("Unrelated", Release("Silence", "Nobody"))); await pg.SaveChangesAsync(); var repo = new TrackRepository( pg, NullLogger>.Instance); var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { SearchText = "jazz" }); Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }), "ILike matches 'jazz' case-insensitively in TrackName, release Artist, or release Title"); } finally { await pg.Database.EnsureDeletedAsync(); } } }