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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user