diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 2a6b247..379b1b8 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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(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); diff --git a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs index 49b4410..6d97717 100644 --- a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs +++ b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs @@ -19,4 +19,5 @@ public record UpdateTrackMetadataRequest( DateOnly? ReleaseDate, string? ImagePath = null, ReleaseType? ReleaseType = null, + ReleaseMedium? Medium = null, int? TrackNumber = null); diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 9e565ca..a568db8 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -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) { diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index eafd155..caafea4 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -95,6 +95,11 @@ public class TrackRepository : Repository 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); diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index d18c1e9..46be53f 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -55,9 +55,10 @@ - @* 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. *@ _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) diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index 8532df6..ab8e880 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -320,6 +320,7 @@ string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"), imgPath, _releaseType, + _medium, trackNumber); if (!linkResult.Success) diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index a1d4f68..3230792 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -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; diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index becea65..759ac45 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -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, }; diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 0be2686..c6b2edb 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -18,10 +18,9 @@ public interface ICmsTrackService /// orphan is handled and logged server-side; here it surfaces as a failed result. /// is the browser's filename, captured at upload time and /// stored as metadata; it is not user-editable afterwards. - /// sets the parent release's . NOTE: the - /// current POST api/track/upload endpoint has no medium 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. + /// sets the parent release's 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, ). /// Task> UploadTrackAsync( Stream wavStream, @@ -80,13 +79,16 @@ public interface ICmsTrackService /// /// Update a track's metadata via PUT api/track/meta/{id}. EntryKey is immutable and /// not part of the update. is tri-state: null leaves the - /// cover art unchanged, "" clears it, and any other value sets it. + /// cover art unchanged, "" clears it, and any other value sets it. + /// 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. /// Task 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); diff --git a/DeepDrftModels/DTOs/TrackFilter.cs b/DeepDrftModels/DTOs/TrackFilter.cs index 8c5b369..eb86ab1 100644 --- a/DeepDrftModels/DTOs/TrackFilter.cs +++ b/DeepDrftModels/DTOs/TrackFilter.cs @@ -16,6 +16,12 @@ public class TrackFilter /// Exact genre match. public string? Genre { get; set; } + /// + /// Exact release-id match. The authoritative join from a release to its tracks — preferred over + /// (a title string that collides across same-titled releases and breaks on rename). + /// + public long? ReleaseId { get; set; } + /// /// 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; } diff --git a/DeepDrftPublic.Client/Clients/TrackClient.cs b/DeepDrftPublic.Client/Clients/TrackClient.cs index f0f0933..aa7adf9 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -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(){ ["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}"); diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index 69b7caa..0df3693 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -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); /// All releases with track counts, title-ascending. Task>> GetAlbums(); diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index ed75763..4d99282 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -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>> GetAlbums() => _trackClient.GetAlbums(); diff --git a/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs b/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs index 90c947a..ce107a4 100644 --- a/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs +++ b/DeepDrftPublic.Client/ViewModels/ReleaseDetailViewModel.cs @@ -6,7 +6,7 @@ namespace DeepDrftPublic.Client.ViewModels; /// /// 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 so a /// reused instance never bleeds across navigations (mirrors TrackDetailViewModel). /// @@ -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(); } diff --git a/DeepDrftTests/MediumWritePathTests.cs b/DeepDrftTests/MediumWritePathTests.cs new file mode 100644 index 0000000..bb43897 --- /dev/null +++ b/DeepDrftTests/MediumWritePathTests.cs @@ -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; + +/// +/// 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. +/// +[TestFixture] +public class MediumWritePathTests +{ + 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 TrackManager CreateManager(TrackRepository repository) + => new(repository, NullLogger>.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 { 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 { 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); + } +}