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 { EntryKey = Guid.NewGuid().ToString("N"), 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 { EntryKey = Guid.NewGuid().ToString("N"), 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); } // 11.G — Description round-trips through both converter directions verbatim (no medium dance, // unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back. [Test] public void Convert_Description_RoundTripsBothDirections() { const string prose = "A late-night set\nrecorded at the Vault."; var entity = new ReleaseEntity { EntryKey = "rk-desc", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session, Description = prose, }; var dto = TrackConverter.Convert(entity); Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description"); var back = TrackConverter.Convert(dto); Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description"); } // 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL). [Test] public void Convert_NullDescription_RoundTripsAsNull() { var entity = new ReleaseEntity { EntryKey = "rk-nulldesc", Title = "Studio Album", Artist = "Artist C", Description = null }; var dto = TrackConverter.Convert(entity); Assert.That(dto.Description, Is.Null); var back = TrackConverter.Convert(dto); Assert.That(back.Description, Is.Null); } // 11.G — Description rides the release-cardinal write channel onto the persisted release row, // exactly as Genre does. FindOrCreateRelease is the upload-path projection point. [Test] public async Task FindOrCreateRelease_NewRelease_PersistsDescription() { const string prose = "Three cuts pressed for the summer."; var manager = CreateManager(CreateRepository()); var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut); data.Description = prose; var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data); Assert.That(result.Success, Is.True); Assert.That(result.Value!.Description, Is.EqualTo(prose)); var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id); Assert.That(stored!.Description, Is.EqualTo(prose)); } // 11.C — editing a track's linked release sets the Description on the persisted release row, // mirroring the PUT api/track/meta apply (release.Description = request.Description). [Test] public async Task Update_SetsReleaseDescription_PersistsDescription() { const string prose = "Now with a proper blurb."; var repo = CreateRepository(); ITrackService manager = CreateManager(repo); var release = new ReleaseEntity { EntryKey = "rk-editdesc", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; 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!.Description = prose; var result = await manager.Update(loaded); Assert.That(result.Success, Is.True); var stored = await CreateRepository().GetReleaseByIdAsync(release.Id); Assert.That(stored!.Description, Is.EqualTo(prose)); } // 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 { EntryKey = "rk-first", Title = "Untitled", Artist = "Artist A" }; var second = new ReleaseEntity { EntryKey = "rk-second", 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 { EntryKey = "rk-first2", Title = "Untitled", Artist = "Artist A" }; var second = new ReleaseEntity { EntryKey = "rk-second2", 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); } // 9.7 — the cardinality declaration is the single source of truth read by both the upload service // and the CMS form collapse. Guard the declared ranges so a drift in MediumRules is caught here. [Test] public void MediumRules_CardinalityOf_DeclaresExpectedRanges() { var cut = MediumRules.CardinalityOf(ReleaseMedium.Cut); Assert.That(cut.Min, Is.EqualTo(1)); Assert.That(cut.Max, Is.EqualTo(int.MaxValue)); Assert.That(cut.IsSingleTrack, Is.False); var session = MediumRules.CardinalityOf(ReleaseMedium.Session); Assert.That(session.Min, Is.EqualTo(1)); Assert.That(session.Max, Is.EqualTo(1)); Assert.That(session.IsSingleTrack, Is.True); var mix = MediumRules.CardinalityOf(ReleaseMedium.Mix); Assert.That(mix.Min, Is.EqualTo(1)); Assert.That(mix.Max, Is.EqualTo(1)); Assert.That(mix.IsSingleTrack, Is.True); } // 9.7 — Allows() bands. The first track always fits; a single-track medium rejects the second; an // unbounded medium accepts any positive count. [Test] public void MediumCardinality_Allows_HonoursTheBand() { var single = MediumRules.CardinalityOf(ReleaseMedium.Session); Assert.That(single.Allows(1), Is.True); Assert.That(single.Allows(2), Is.False); var many = MediumRules.CardinalityOf(ReleaseMedium.Cut); Assert.That(many.Allows(1), Is.True); Assert.That(many.Allows(50), Is.True); } // 9.7 — GetReleaseByTitleAndArtist is the read-only peek the upload pre-check reads. It surfaces // the stored medium and the live-track count without creating a release. Null on miss. [Test] public async Task GetReleaseByTitleAndArtist_ExistingRelease_ReturnsMediumAndLiveCount() { var repo = CreateRepository(); ITrackService manager = CreateManager(repo); var release = new ReleaseEntity { EntryKey = "rk-peek", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session }; _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release }); await _context.SaveChangesAsync(); var peek = await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A"); Assert.That(peek.Success, Is.True); Assert.That(peek.Value, Is.Not.Null); Assert.That(peek.Value!.Medium, Is.EqualTo(ReleaseMedium.Session)); Assert.That(peek.Value.TrackCount, Is.EqualTo(1)); } [Test] public async Task GetReleaseByTitleAndArtist_NoSuchRelease_ReturnsNullWithoutCreating() { ITrackService manager = CreateManager(CreateRepository()); var peek = await manager.GetReleaseByTitleAndArtist("Nothing Here", "Nobody"); Assert.That(peek.Success, Is.True); Assert.That(peek.Value, Is.Null); // The peek must not have created a release for a non-existent (title, artist). var releases = (await manager.GetReleases()).Value!; Assert.That(releases, Is.Empty); } // 9.7 — the cardinality decision the orchestrator makes, asserted over the SQL-layer seam it reads. // A Session release that already holds its single track REJECTS a second track-add: the peek's // live count + 1 exceeds the medium's Max. (The orchestrator's vault write spans the FileDatabase, // not reachable from this in-memory fixture — see the orphan-avoidance note in the handoff.) [Test] public async Task CardinalityDecision_SessionWithOneTrack_RejectsSecondAdd() { var repo = CreateRepository(); ITrackService manager = CreateManager(repo); var release = new ReleaseEntity { EntryKey = "rk-cardses", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session }; _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release }); await _context.SaveChangesAsync(); var peek = (await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!; var max = MediumRules.CardinalityOf(peek.Medium).Max; Assert.That(peek.TrackCount + 1 > max, Is.True, "second track-add to a single-track Session is over-limit"); } // 9.7 — Mix mirrors Session: a Mix holding its one track rejects a second add. [Test] public async Task CardinalityDecision_MixWithOneTrack_RejectsSecondAdd() { var repo = CreateRepository(); ITrackService manager = CreateManager(repo); var release = new ReleaseEntity { EntryKey = "rk-cardmix", Title = "Sunset Set", Artist = "DJ B", Medium = ReleaseMedium.Mix }; _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "The Set", Release = release }); await _context.SaveChangesAsync(); var peek = (await manager.GetReleaseByTitleAndArtist("Sunset Set", "DJ B")).Value!; var max = MediumRules.CardinalityOf(peek.Medium).Max; Assert.That(peek.TrackCount + 1 > max, Is.True); } // 9.7 — a Cut release accepts the 2nd and Nth track-add: the unbounded Max is never exceeded. [Test] public async Task CardinalityDecision_CutWithManyTracks_AcceptsFurtherAdds() { var repo = CreateRepository(); ITrackService manager = CreateManager(repo); var release = new ReleaseEntity { EntryKey = "rk-cardcut", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; _context.Tracks.AddRange( new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release }, new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release }, new TrackEntity { EntryKey = "c3", TrackName = "Three", Release = release }); await _context.SaveChangesAsync(); var peek = (await manager.GetReleaseByTitleAndArtist("Studio Album", "Artist C")).Value!; var max = MediumRules.CardinalityOf(peek.Medium).Max; Assert.That(peek.TrackCount, Is.EqualTo(3)); Assert.That(peek.TrackCount + 1 > max, Is.False, "Cut is unbounded — a 4th track is admitted"); } // 9.7 — the first track on a new Session/Mix succeeds: with no existing release the peek returns // null, so the pre-check never fires and the create path admits the 0→1 add (within 1..1). [Test] public async Task CardinalityDecision_FirstTrackOnNewSession_IsAdmitted() { ITrackService manager = CreateManager(CreateRepository()); // No release exists yet for this (title, artist). var peek = await manager.GetReleaseByTitleAndArtist("Brand New Session", "Artist D"); Assert.That(peek.Value, Is.Null, "no release means the cardinality pre-check is skipped — the create path admits the first track"); } }