Enforce per-medium track cardinality in the upload service via MediumRules

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.
This commit is contained in:
daniel-c-harvey
2026-06-13 14:12:01 -04:00
parent 6f42464294
commit b893ca84de
8 changed files with 261 additions and 9 deletions
+140
View File
@@ -213,4 +213,144 @@ public class MediumWritePathTests
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");
}
}