feat: normalize release-cardinal fields out of track into a Release entity (Phase 8 §8.0)
This commit is contained in:
@@ -10,15 +10,18 @@ using Models.Common;
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
|
||||
/// 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 <see cref="ReleaseEntity"/>, reached through the nullable Release navigation. Tracks
|
||||
/// link via <c>ReleaseId</c>; 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, 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.
|
||||
/// covers exact-match equality through the navigation, 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
|
||||
@@ -43,16 +46,23 @@ public class TrackFilterQueryTests
|
||||
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)
|
||||
private static ReleaseEntity Release(
|
||||
string title, string artist, string? genre = null, string? image = null)
|
||||
=> new()
|
||||
{
|
||||
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,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ImagePath = image,
|
||||
Release = release,
|
||||
};
|
||||
|
||||
private async Task SeedAsync(params TrackEntity[] tracks)
|
||||
@@ -64,16 +74,18 @@ public class TrackFilterQueryTests
|
||||
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.
|
||||
// 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", "A", album: "Blue"),
|
||||
Track("Two", "B", album: "Blue"),
|
||||
Track("Three", "C", album: "Red"),
|
||||
Track("Four", "D", album: null));
|
||||
Track("One", blue),
|
||||
Track("Two", blue),
|
||||
Track("Three", red),
|
||||
Track("Four"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
|
||||
@@ -82,14 +94,14 @@ public class TrackFilterQueryTests
|
||||
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
|
||||
}
|
||||
|
||||
// Case 2b — exact genre match composes the same way as album.
|
||||
// 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", "A", genre: "Techno"),
|
||||
Track("Two", "B", genre: "House"),
|
||||
Track("Three", "C", genre: "Techno"));
|
||||
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" });
|
||||
@@ -103,9 +115,9 @@ public class TrackFilterQueryTests
|
||||
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
|
||||
{
|
||||
await SeedAsync(
|
||||
Track("One", "A", album: "Blue"),
|
||||
Track("Two", "B", album: "Red"),
|
||||
Track("Three", "C"));
|
||||
Track("One", Release("Blue", "A")),
|
||||
Track("Two", Release("Red", "B")),
|
||||
Track("Three"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var baseline = await repo.GetPagedAsync(DefaultPaging());
|
||||
@@ -117,56 +129,98 @@ public class TrackFilterQueryTests
|
||||
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.
|
||||
// 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 GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
|
||||
public async Task GetReleasesAsync_ReturnsAllReleasesTitleAscending()
|
||||
{
|
||||
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"));
|
||||
Track("One", Release("Zephyr", "A", image: "cover-z")),
|
||||
Track("Two", Release("Aria", "B", image: "cover-a")),
|
||||
Track("Three"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var albums = await repo.GetDistinctAlbumsAsync();
|
||||
var releases = await repo.GetReleasesAsync();
|
||||
|
||||
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"));
|
||||
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 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
|
||||
// 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", "A", genre: "Techno"),
|
||||
Track("Two", "B", genre: "Ambient"),
|
||||
Track("Three", "C", genre: "Techno"),
|
||||
Track("Four", "D", genre: null));
|
||||
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 track is excluded");
|
||||
"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 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.
|
||||
// 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()
|
||||
{
|
||||
@@ -183,10 +237,10 @@ public class TrackFilterQueryTests
|
||||
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"));
|
||||
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(
|
||||
@@ -195,7 +249,7 @@ public class TrackFilterQueryTests
|
||||
|
||||
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");
|
||||
"ILike matches 'jazz' case-insensitively in TrackName, release Artist, or release Title");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user