Make release Medium writable via upload + meta-edit; resolve detail-page track by releaseId not album title

This commit is contained in:
daniel-c-harvey
2026-06-13 11:34:45 -04:00
parent ea018beb3e
commit 8b62915083
15 changed files with 314 additions and 28 deletions
+36 -3
View File
@@ -48,9 +48,10 @@ public class TrackController : ControllerBase
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering).
// q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
[HttpGet("page")]
public async Task<ActionResult> GetPage(
[FromQuery] int page = 1,
@@ -60,9 +61,10 @@ public class TrackController : ControllerBase
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
[FromQuery] long? releaseId = null,
CancellationToken cancellationToken = default)
{
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre };
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
var effectiveFilter = filter.IsEmpty ? null : filter;
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
@@ -191,6 +193,7 @@ public class TrackController : ControllerBase
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
[FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber,
CancellationToken cancellationToken)
{
@@ -242,6 +245,21 @@ public class TrackController : ControllerBase
if (!string.IsNullOrWhiteSpace(releaseType))
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
}
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
ReleaseMedium parsedMedium;
if (!string.IsNullOrWhiteSpace(medium)
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
&& Enum.IsDefined(rm))
{
parsedMedium = rm;
}
else
{
parsedMedium = ReleaseMedium.Cut;
if (!string.IsNullOrWhiteSpace(medium))
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
}
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry
@@ -269,6 +287,7 @@ public class TrackController : ControllerBase
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
parsedReleaseType,
parsedMedium,
resolvedTrackNumber,
cancellationToken);
@@ -393,6 +412,20 @@ public class TrackController : ControllerBase
// ReleaseType is non-null on the release; null in the request means "no change".
if (request.ReleaseType is not null)
release.ReleaseType = request.ReleaseType.Value;
// Medium is non-null on the release; null in the request means "no change".
if (request.Medium is not null)
{
release.Medium = request.Medium.Value;
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
// ReleaseType sent in the same request alongside a non-Cut medium.
if (request.Medium.Value != ReleaseMedium.Cut)
release.ReleaseType = ReleaseType.Single;
}
}
var update = await _sqlTrackService.Update(track);
@@ -19,4 +19,5 @@ public record UpdateTrackMetadataRequest(
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
ReleaseMedium? Medium = null,
int? TrackNumber = null);
@@ -53,6 +53,7 @@ public class UnifiedTrackService
long createdByUserId,
string? originalFileName,
ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber,
CancellationToken ct)
{
@@ -81,9 +82,15 @@ public class UnifiedTrackService
Genre = genre,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
Medium = medium,
CreatedByUserId = createdByUserId,
};
// Medium (like every other field in releaseData) applies only when this upload CREATES the
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
// subsequent track add: medium is a release-level property, changed only via the edit path
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null)
{
@@ -95,6 +95,11 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
// Exact release-id join. ReleaseId is a column on the track itself, so this needs no
// navigation guard — it is the authoritative alternative to the Album title match.
if (filter.ReleaseId is { } releaseId)
query = query.Where(t => t.ReleaseId == releaseId);
}
var totalCount = await query.CountAsync(ct);
@@ -55,9 +55,10 @@
<MudGrid>
<MudItem xs="12" md="5">
@* TODO: When medium write path lands, collapse to single-track slot here for Session/Mix
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard). Until then,
BatchEdit's track list is unrestricted because _medium is read-only on the edit form. *@
@* The medium write path persists _medium on save (9.5.B). BatchEdit still shows the full
multi-track list for every medium; collapsing to a single-track slot for Session/Mix
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard) is deferred form-shape
work, not part of the write-path wiring. *@
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_saving"
@@ -131,9 +132,9 @@
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The medium selector drives ReleaseType visibility. NOTE: the metadata update endpoint
// (PUT api/track/meta) has no medium field, so a medium change is not persisted on save today —
// the selector reflects/adjusts local form shape only. Persisting medium-on-edit is an API change.
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
// default server-side for a non-Cut medium.
private void OnMediumChanged(ReleaseMedium medium) => _medium = medium;
protected override async Task OnInitializedAsync()
@@ -373,6 +374,7 @@
releaseDate,
imagePathForUpdate,
_releaseType,
_medium,
trackNumber);
if (!updateResult.Success)
@@ -407,7 +409,8 @@
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
trackNumber,
_medium);
if (!uploadResult.Success || uploadResult.Value is null)
{
@@ -433,6 +436,7 @@
releaseDate,
linkPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
@@ -320,6 +320,7 @@
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
@@ -210,6 +210,7 @@
releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
_form.ReleaseType,
_form.Medium,
_form.TrackNumber);
if (updated.Success)
{
@@ -298,8 +299,8 @@
public DateTime? ReleaseDate { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
// Drives ReleaseType visibility via MediumFields. NOTE: not persisted PUT api/track/meta has
// no medium field, so a medium change on edit is form-shape only until the API grows one.
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public int TrackNumber { get; set; } = 1;
+4 -2
View File
@@ -64,8 +64,8 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
// Forward-compatible: the upload endpoint does not bind a "medium" field yet (server defaults
// to Cut). Sent so the value round-trips once the API grows the parameter; ignored until then.
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -374,6 +374,7 @@ public class CmsTrackService : ICmsTrackService
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
int? trackNumber = null,
CancellationToken ct = default)
{
@@ -387,6 +388,7 @@ public class CmsTrackService : ICmsTrackService
releaseDate,
imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
medium = medium.HasValue ? (int?)medium.Value : null,
trackNumber,
};
+7 -5
View File
@@ -18,10 +18,9 @@ public interface ICmsTrackService
/// orphan is handled and logged server-side; here it surfaces as a failed result.
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
/// stored as metadata; it is not user-editable afterwards.
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/>. NOTE: the
/// current <c>POST api/track/upload</c> endpoint has no <c>medium</c> form field, so the value is
/// sent forward-compatibly and ignored server-side until the API binds it (Cut is the server
/// default). Wiring the selector through here keeps the CMS ready for that API change.
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/> when this upload
/// creates the release. The medium is authoritative only on creation — adding a track to an existing
/// release never changes its medium (that is the edit path, <see cref="UpdateAsync"/>).
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -80,13 +79,16 @@ public interface ICmsTrackService
/// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
/// cover art unchanged, <c>""</c> clears it, and any other value sets it. <paramref name="medium"/>
/// is null = no change; a non-null, non-Cut value resets the release's ReleaseType to its default
/// server-side, since ReleaseType is meaningful only for Cut.
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
int? trackNumber = null,
CancellationToken ct = default);
+8 -1
View File
@@ -16,6 +16,12 @@ public class TrackFilter
/// <summary>Exact genre match.</summary>
public string? Genre { get; set; }
/// <summary>
/// Exact release-id match. The authoritative join from a release to its tracks — preferred over
/// <see cref="Album"/> (a title string that collides across same-titled releases and breaks on rename).
/// </summary>
public long? ReleaseId { get; set; }
/// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying.
@@ -23,5 +29,6 @@ public class TrackFilter
public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Album)
&& string.IsNullOrWhiteSpace(Genre);
&& string.IsNullOrWhiteSpace(Genre)
&& ReleaseId is null;
}
+5 -1
View File
@@ -23,7 +23,8 @@ public class TrackClient
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
string? genre = null,
long? releaseId = null)
{
var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(),
@@ -45,6 +46,9 @@ public class TrackClient
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
if (releaseId is { } id)
queryArgs["releaseId"] = id.ToString();
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}");
@@ -19,7 +19,8 @@ public interface ITrackDataService
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null);
string? genre = null,
long? releaseId = null);
/// <summary>All releases with track counts, title-ascending.</summary>
Task<ApiResult<List<ReleaseDto>>> GetAlbums();
@@ -26,8 +26,9 @@ public class TrackClientDataService : ITrackDataService
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
string? genre = null,
long? releaseId = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre, releaseId);
public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
=> _trackClient.GetAlbums();
@@ -6,7 +6,7 @@ namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for a single-release detail page (Session, Mix). Loads the release and resolves its
/// playable track. The release read surface exposes no track entry directly, so the playable track
/// is resolved through the existing track gallery filtered by the release's album title — for
/// is resolved through the existing track gallery filtered by the release's id (an exact join) — for
/// Session/Mix that yields the single track. Scoped; reset every flag per <see cref="Load"/> so a
/// reused instance never bleeds across navigations (mirrors TrackDetailViewModel).
/// </summary>
@@ -53,11 +53,12 @@ public class ReleaseDetailViewModel
Release = release;
// Resolve the playable track via the album-filtered track page. Session/Mix releases
// carry a single track; take the first. A release with no streamable track simply
// Resolve the playable track via the releaseId-filtered track page — an exact join, not a
// title string (which collides across same-titled releases and breaks on rename). Session/Mix
// releases carry a single track; take the first. A release with no streamable track simply
// leaves Track null (the detail page hides the play affordance).
var trackResult = await _trackData.GetPage(
pageNumber: 1, pageSize: 1, album: release.Title);
pageNumber: 1, pageSize: 1, releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } })
Track = items.FirstOrDefault();
}
+216
View File
@@ -0,0 +1,216 @@
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftData;
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;
/// <summary>
/// Phase 9.5 medium write-path coverage. Exercises the SQL layer that carries the medium through the
/// upload and edit flows (TrackManager + TrackRepository), plus the releaseId track-resolution filter
/// (9.5.C). Runs on the EF in-memory provider, which executes every predicate here in process —
/// release creation, the no-mutation-on-find rule, the medium update + ReleaseType reset, and exact
/// releaseId equality.
///
/// The controller-level form/JSON parse and the ReleaseType-reset conditional (9.5.B) live in
/// TrackController; this fixture asserts the persisted outcome of that logic by driving the same
/// service surface the controller calls (FindOrCreateRelease for upload, ITrackService.Update for
/// the meta edit), so a regression in the data layer that backs the medium write path is caught.
/// </summary>
[TestFixture]
public class MediumWritePathTests
{
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 TrackManager CreateManager(TrackRepository repository)
=> new(repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
private static ReleaseDto ReleaseData(string title, string artist, ReleaseMedium medium)
=> new() { Title = title, Artist = artist, Medium = medium };
// 9.5.A — a Session upload creates a release carrying Medium == Session.
[Test]
public async Task FindOrCreateRelease_NewSessionRelease_PersistsMediumSession()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
// 9.5.A — a Mix upload creates a release carrying Medium == Mix.
[Test]
public async Task FindOrCreateRelease_NewMixRelease_PersistsMediumMix()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
[Test]
public async Task FindOrCreateRelease_NewCutRelease_PersistsMediumCut()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
// upload's medium is authoritative; a Cut-typed follow-up upload must not flip a Session release.
[Test]
public async Task FindOrCreateRelease_ExistingRelease_DoesNotMutateMedium()
{
var repo = CreateRepository();
var manager = CreateManager(repo);
var created = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
// Second add to the same (title, artist) arrives carrying Cut — the find path must ignore it.
var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
// 9.5.B — updating a track's release to a non-Cut medium persists the new medium. Mirrors the
// PUT api/track/meta apply: the controller sets release.Medium, the manager saves the linked release.
[Test]
public async Task Update_FlipsCutReleaseToSession_PersistsMedium()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity
{
Title = "Originally a Cut", Artist = "Artist A",
Medium = ReleaseMedium.Cut, ReleaseType = ReleaseType.EP,
};
var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
_context.Tracks.Add(track);
await _context.SaveChangesAsync();
var loaded = (await manager.GetById(track.Id)).Value!;
loaded.Release!.Medium = ReleaseMedium.Session;
// The controller resets ReleaseType to the default when medium goes non-Cut; replicate so the
// edited DTO matches what the controller would persist.
loaded.Release.ReleaseType = ReleaseType.Single;
var result = await manager.Update(loaded);
Assert.That(result.Success, Is.True);
var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(stored.ReleaseType, Is.EqualTo(ReleaseType.Single), "ReleaseType reset to default for a non-Cut medium");
}
// 9.5.B — the read-path converter already enforces the ReleaseType-only-for-Cut invariant: a
// non-Cut release surfaces a null ReleaseType regardless of the stale column value. This is the
// invariant the write-path reset mirrors, asserted at the single mapping point.
[Test]
public void Convert_NonCutRelease_NullsReleaseTypeOnRead()
{
var sessionWithStaleType = new ReleaseEntity
{
Title = "Session", Artist = "A",
Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album,
};
var dto = TrackConverter.Convert(sessionWithStaleType);
Assert.That(dto.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(dto.ReleaseType, Is.Null);
}
// 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository
// directly to assert the WHERE release_id predicate in isolation.
[Test]
public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks()
{
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first },
new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
await _context.SaveChangesAsync();
var repo = CreateRepository();
var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };
var result = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "A-One", "A-Two" }));
}
// 9.5.C — two same-titled releases resolve distinctly by id, the exact failure album-title join
// could not survive. Each releaseId returns only its own track.
[Test]
public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById()
{
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
await _context.SaveChangesAsync();
var repo = CreateRepository();
var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };
var firstResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });
var secondResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = second.Id });
Assert.That(firstResult.Items.Single().TrackName, Is.EqualTo("A-One"));
Assert.That(secondResult.Items.Single().TrackName, Is.EqualTo("B-One"));
}
// 9.5.C — TrackFilter.IsEmpty accounts for ReleaseId, so a releaseId-only filter is not collapsed
// to a null passthrough by the manager's effectiveFilter guard.
[Test]
public void TrackFilter_WithOnlyReleaseId_IsNotEmpty()
{
Assert.That(new TrackFilter { ReleaseId = 5 }.IsEmpty, Is.False);
Assert.That(new TrackFilter().IsEmpty, Is.True);
}
}