206 lines
8.4 KiB
C#
206 lines
8.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
|
|
///
|
|
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
|
|
/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate
|
|
/// in <see cref="TrackRepository.GetPagedFilteredAsync"/> 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 SearchText_*). It is
|
|
/// ignored when no test database is configured rather than asserted against a provider that never
|
|
/// runs the predicate.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class TrackFilterQueryTests
|
|
{
|
|
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 TrackRepository CreateRepository()
|
|
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
|
|
|
private static TrackEntity Track(
|
|
string name, string artist, string? album = null, string? genre = null, string? image = null)
|
|
=> new()
|
|
{
|
|
EntryKey = Guid.NewGuid().ToString("N"),
|
|
TrackName = name,
|
|
Artist = artist,
|
|
Album = album,
|
|
Genre = genre,
|
|
ImagePath = image,
|
|
};
|
|
|
|
private async Task SeedAsync(params TrackEntity[] tracks)
|
|
{
|
|
_context.Tracks.AddRange(tracks);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
private static PagingParameters<TrackEntity> DefaultPaging()
|
|
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
|
|
|
|
// Case 2 — exact album match: returns only rows whose Album equals the filter value, and
|
|
// TotalCount reflects the filtered set, not the table.
|
|
[Test]
|
|
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
|
|
{
|
|
await SeedAsync(
|
|
Track("One", "A", album: "Blue"),
|
|
Track("Two", "B", album: "Blue"),
|
|
Track("Three", "C", album: "Red"),
|
|
Track("Four", "D", album: null));
|
|
|
|
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.
|
|
[Test]
|
|
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
|
|
{
|
|
await SeedAsync(
|
|
Track("One", "A", genre: "Techno"),
|
|
Track("Two", "B", genre: "House"),
|
|
Track("Three", "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", "A", album: "Blue"),
|
|
Track("Two", "B", album: "Red"),
|
|
Track("Three", "C"));
|
|
|
|
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 — distinct albums: excludes null-album rows, counts per group, and takes the cover from
|
|
// the first track in the group that has a non-null ImagePath. Ordered by album ascending.
|
|
[Test]
|
|
public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
|
|
{
|
|
await SeedAsync(
|
|
Track("One", "A", album: "Zephyr", image: null),
|
|
Track("Two", "A", album: "Zephyr", image: "cover-z"),
|
|
Track("Three", "B", album: "Aria", image: "cover-a"),
|
|
Track("Four", "C", album: null, image: "ignored"));
|
|
|
|
var repo = CreateRepository();
|
|
var albums = await repo.GetDistinctAlbumsAsync();
|
|
|
|
Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
|
|
"albums sort ascending and the null-album track is excluded");
|
|
|
|
var zephyr = albums.Single(a => a.Album == "Zephyr");
|
|
Assert.That(zephyr.TrackCount, Is.EqualTo(2));
|
|
Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"),
|
|
"cover is the first non-null ImagePath in the group");
|
|
|
|
var aria = albums.Single(a => a.Album == "Aria");
|
|
Assert.That(aria.TrackCount, Is.EqualTo(1));
|
|
Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a"));
|
|
}
|
|
|
|
// Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
|
|
[Test]
|
|
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
|
|
{
|
|
await SeedAsync(
|
|
Track("One", "A", genre: "Techno"),
|
|
Track("Two", "B", genre: "Ambient"),
|
|
Track("Three", "C", genre: "Techno"),
|
|
Track("Four", "D", genre: null));
|
|
|
|
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 track 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 1 — free-text search across TrackName/Artist/Album, 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<DeepDrftContext>()
|
|
.UseNpgsql(dsn)
|
|
.Options;
|
|
await using var pg = new DeepDrftContext(options);
|
|
await pg.Database.EnsureCreatedAsync();
|
|
|
|
try
|
|
{
|
|
pg.Tracks.AddRange(
|
|
Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"),
|
|
Track("Quiet Storm", "jazzmin", album: "Nightfall"),
|
|
Track("Loud Noises", "Brick", album: "All JAZZ Hands"),
|
|
Track("Unrelated", "Nobody", album: "Silence"));
|
|
await pg.SaveChangesAsync();
|
|
|
|
var repo = new TrackRepository(
|
|
pg, NullLogger<Repository<DeepDrftContext, TrackEntity>>.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, Artist, or Album");
|
|
}
|
|
finally
|
|
{
|
|
await pg.Database.EnsureDeletedAsync();
|
|
}
|
|
}
|
|
}
|