Merge p9-w5-t1-medium-write-path into dev (9.5.A/B/C)
This commit is contained in:
@@ -48,9 +48,10 @@ public class TrackController : ControllerBase
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
|
||||
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
|
||||
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering).
|
||||
// q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
|
||||
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
|
||||
[HttpGet("page")]
|
||||
public async Task<ActionResult> GetPage(
|
||||
[FromQuery] int page = 1,
|
||||
@@ -60,9 +61,10 @@ public class TrackController : ControllerBase
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? album = null,
|
||||
[FromQuery] string? genre = null,
|
||||
[FromQuery] long? releaseId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre };
|
||||
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
|
||||
var effectiveFilter = filter.IsEmpty ? null : filter;
|
||||
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
|
||||
@@ -191,6 +193,7 @@ public class TrackController : ControllerBase
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
[FromForm] string? releaseType,
|
||||
[FromForm] string? medium,
|
||||
[FromForm] int? trackNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -242,6 +245,21 @@ public class TrackController : ControllerBase
|
||||
if (!string.IsNullOrWhiteSpace(releaseType))
|
||||
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
|
||||
}
|
||||
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
|
||||
ReleaseMedium parsedMedium;
|
||||
if (!string.IsNullOrWhiteSpace(medium)
|
||||
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
|
||||
&& Enum.IsDefined(rm))
|
||||
{
|
||||
parsedMedium = rm;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedMedium = ReleaseMedium.Cut;
|
||||
if (!string.IsNullOrWhiteSpace(medium))
|
||||
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
|
||||
}
|
||||
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
@@ -269,6 +287,7 @@ public class TrackController : ControllerBase
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
parsedReleaseType,
|
||||
parsedMedium,
|
||||
resolvedTrackNumber,
|
||||
cancellationToken);
|
||||
|
||||
@@ -393,6 +412,20 @@ public class TrackController : ControllerBase
|
||||
// ReleaseType is non-null on the release; null in the request means "no change".
|
||||
if (request.ReleaseType is not null)
|
||||
release.ReleaseType = request.ReleaseType.Value;
|
||||
|
||||
// Medium is non-null on the release; null in the request means "no change".
|
||||
if (request.Medium is not null)
|
||||
{
|
||||
release.Medium = request.Medium.Value;
|
||||
|
||||
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
|
||||
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
|
||||
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
|
||||
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
|
||||
// ReleaseType sent in the same request alongside a non-Cut medium.
|
||||
if (request.Medium.Value != ReleaseMedium.Cut)
|
||||
release.ReleaseType = ReleaseType.Single;
|
||||
}
|
||||
}
|
||||
|
||||
var update = await _sqlTrackService.Update(track);
|
||||
|
||||
@@ -19,4 +19,5 @@ public record UpdateTrackMetadataRequest(
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
ReleaseMedium? Medium = null,
|
||||
int? TrackNumber = null);
|
||||
|
||||
@@ -53,6 +53,7 @@ public class UnifiedTrackService
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
ReleaseType releaseType,
|
||||
ReleaseMedium medium,
|
||||
int trackNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -81,9 +82,15 @@ public class UnifiedTrackService
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
// Medium (like every other field in releaseData) applies only when this upload CREATES the
|
||||
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
|
||||
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
|
||||
// subsequent track add: medium is a release-level property, changed only via the edit path
|
||||
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success || releaseResult.Value is null)
|
||||
{
|
||||
|
||||
@@ -95,6 +95,11 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||
|
||||
// Exact release-id join. ReleaseId is a column on the track itself, so this needs no
|
||||
// navigation guard — it is the authoritative alternative to the Album title match.
|
||||
if (filter.ReleaseId is { } releaseId)
|
||||
query = query.Where(t => t.ReleaseId == releaseId);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
@@ -55,9 +55,10 @@
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
@* TODO: When medium write path lands, collapse to single-track slot here for Session/Mix
|
||||
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard). Until then,
|
||||
BatchEdit's track list is unrestricted because _medium is read-only on the edit form. *@
|
||||
@* The medium write path persists _medium on save (9.5.B). BatchEdit still shows the full
|
||||
multi-track list for every medium; collapsing to a single-track slot for Session/Mix
|
||||
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard) is deferred form-shape
|
||||
work, not part of the write-path wiring. *@
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
@@ -131,9 +132,9 @@
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
|
||||
// The medium selector drives ReleaseType visibility. NOTE: the metadata update endpoint
|
||||
// (PUT api/track/meta) has no medium field, so a medium change is not persisted on save today —
|
||||
// the selector reflects/adjusts local form shape only. Persisting medium-on-edit is an API change.
|
||||
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
|
||||
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
|
||||
// default server-side for a non-Cut medium.
|
||||
private void OnMediumChanged(ReleaseMedium medium) => _medium = medium;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -373,6 +374,7 @@
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!updateResult.Success)
|
||||
@@ -407,7 +409,8 @@
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
trackNumber,
|
||||
_medium);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
{
|
||||
@@ -433,6 +436,7 @@
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
_medium,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
||||
_form.ReleaseType,
|
||||
_form.Medium,
|
||||
_form.TrackNumber);
|
||||
if (updated.Success)
|
||||
{
|
||||
@@ -298,8 +299,8 @@
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
// Drives ReleaseType visibility via MediumFields. NOTE: not persisted — PUT api/track/meta has
|
||||
// no medium field, so a medium change on edit is form-shape only until the API grows one.
|
||||
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
|
||||
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ public class CmsTrackService : ICmsTrackService
|
||||
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
|
||||
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
|
||||
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
|
||||
// Forward-compatible: the upload endpoint does not bind a "medium" field yet (server defaults
|
||||
// to Cut). Sent so the value round-trips once the API grows the parameter; ignored until then.
|
||||
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
|
||||
// for an unrecognised value). Authoritative only when this upload creates the release.
|
||||
multipart.Add(new StringContent(medium.ToString()), "medium");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -374,6 +374,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -387,6 +388,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
medium = medium.HasValue ? (int?)medium.Value : null,
|
||||
trackNumber,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ public interface ICmsTrackService
|
||||
/// orphan is handled and logged server-side; here it surfaces as a failed result.
|
||||
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
|
||||
/// stored as metadata; it is not user-editable afterwards.
|
||||
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/>. NOTE: the
|
||||
/// current <c>POST api/track/upload</c> endpoint has no <c>medium</c> form field, so the value is
|
||||
/// sent forward-compatibly and ignored server-side until the API binds it (Cut is the server
|
||||
/// default). Wiring the selector through here keeps the CMS ready for that API change.
|
||||
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/> when this upload
|
||||
/// creates the release. The medium is authoritative only on creation — adding a track to an existing
|
||||
/// release never changes its medium (that is the edit path, <see cref="UpdateAsync"/>).
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
@@ -80,13 +79,16 @@ public interface ICmsTrackService
|
||||
/// <summary>
|
||||
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
|
||||
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
|
||||
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
|
||||
/// cover art unchanged, <c>""</c> clears it, and any other value sets it. <paramref name="medium"/>
|
||||
/// is null = no change; a non-null, non-Cut value resets the release's ReleaseType to its default
|
||||
/// server-side, since ReleaseType is meaningful only for Cut.
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ public class TrackFilter
|
||||
/// <summary>Exact genre match.</summary>
|
||||
public string? Genre { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exact release-id match. The authoritative join from a release to its tracks — preferred over
|
||||
/// <see cref="Album"/> (a title string that collides across same-titled releases and breaks on rename).
|
||||
/// </summary>
|
||||
public long? ReleaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when no predicate is set. An empty filter must produce identical results to a null
|
||||
/// filter, so callers collapse it to null before querying.
|
||||
@@ -23,5 +29,6 @@ public class TrackFilter
|
||||
public bool IsEmpty =>
|
||||
string.IsNullOrWhiteSpace(SearchText)
|
||||
&& string.IsNullOrWhiteSpace(Album)
|
||||
&& string.IsNullOrWhiteSpace(Genre);
|
||||
&& string.IsNullOrWhiteSpace(Genre)
|
||||
&& ReleaseId is null;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ public class TrackClient
|
||||
bool sortDescending = false,
|
||||
string? searchText = null,
|
||||
string? album = null,
|
||||
string? genre = null)
|
||||
string? genre = null,
|
||||
long? releaseId = null)
|
||||
{
|
||||
var queryArgs = new Dictionary<string, string?>(){
|
||||
["page"] = pageNumber.ToString(),
|
||||
@@ -45,6 +46,9 @@ public class TrackClient
|
||||
if (!string.IsNullOrEmpty(genre))
|
||||
queryArgs["genre"] = genre;
|
||||
|
||||
if (releaseId is { } id)
|
||||
queryArgs["releaseId"] = id.ToString();
|
||||
|
||||
string query = QueryString.Create(queryArgs).ToString();
|
||||
|
||||
var response = await _http.GetAsync($"api/track/page{query}");
|
||||
|
||||
@@ -19,7 +19,8 @@ public interface ITrackDataService
|
||||
bool sortDescending = false,
|
||||
string? searchText = null,
|
||||
string? album = null,
|
||||
string? genre = null);
|
||||
string? genre = null,
|
||||
long? releaseId = null);
|
||||
|
||||
/// <summary>All releases with track counts, title-ascending.</summary>
|
||||
Task<ApiResult<List<ReleaseDto>>> GetAlbums();
|
||||
|
||||
@@ -26,8 +26,9 @@ public class TrackClientDataService : ITrackDataService
|
||||
bool sortDescending = false,
|
||||
string? searchText = null,
|
||||
string? album = null,
|
||||
string? genre = null)
|
||||
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
|
||||
string? genre = null,
|
||||
long? releaseId = null)
|
||||
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre, releaseId);
|
||||
|
||||
public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||
=> _trackClient.GetAlbums();
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace DeepDrftPublic.Client.ViewModels;
|
||||
/// <summary>
|
||||
/// State for a single-release detail page (Session, Mix). Loads the release and resolves its
|
||||
/// playable track. The release read surface exposes no track entry directly, so the playable track
|
||||
/// is resolved through the existing track gallery filtered by the release's album title — for
|
||||
/// is resolved through the existing track gallery filtered by the release's id (an exact join) — for
|
||||
/// Session/Mix that yields the single track. Scoped; reset every flag per <see cref="Load"/> so a
|
||||
/// reused instance never bleeds across navigations (mirrors TrackDetailViewModel).
|
||||
/// </summary>
|
||||
@@ -53,11 +53,12 @@ public class ReleaseDetailViewModel
|
||||
|
||||
Release = release;
|
||||
|
||||
// Resolve the playable track via the album-filtered track page. Session/Mix releases
|
||||
// carry a single track; take the first. A release with no streamable track simply
|
||||
// Resolve the playable track via the releaseId-filtered track page — an exact join, not a
|
||||
// title string (which collides across same-titled releases and breaks on rename). Session/Mix
|
||||
// releases carry a single track; take the first. A release with no streamable track simply
|
||||
// leaves Track null (the detail page hides the play affordance).
|
||||
var trackResult = await _trackData.GetPage(
|
||||
pageNumber: 1, pageSize: 1, album: release.Title);
|
||||
pageNumber: 1, pageSize: 1, releaseId: release.Id);
|
||||
if (trackResult is { Success: true, Value: { Items: { } items } })
|
||||
Track = items.FirstOrDefault();
|
||||
}
|
||||
|
||||
@@ -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