b893ca84de
Promote the Session/Mix single-track rule from a CMS-form convention to a domain invariant: declare cardinality as data in MediumRules, enforce it in UnifiedTrackService before the vault write (no orphan), return 409, and read the same rule in the batch-form collapse.
357 lines
16 KiB
C#
357 lines
16 KiB
C#
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);
|
|
}
|
|
|
|
// 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 { 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 { 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 { 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 { 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");
|
|
}
|
|
}
|