Make release Medium writable via upload + meta-edit; resolve detail-page track by releaseId not album title
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user