Merge p11-w1-description-schema into dev (P11 11.G: release Description field, migration authored)

This commit is contained in:
daniel-c-harvey
2026-06-16 00:38:13 -04:00
17 changed files with 463 additions and 4 deletions
@@ -189,6 +189,7 @@ public class TrackController : ControllerBase
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? description,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
@@ -283,6 +284,7 @@ public class TrackController : ControllerBase
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre,
string.IsNullOrWhiteSpace(description) ? null : description,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
@@ -412,6 +414,7 @@ public class TrackController : ControllerBase
release.Artist = request.Artist;
release.Title = request.Album ?? string.Empty;
release.Genre = request.Genre;
release.Description = request.Description;
release.ReleaseDate = request.ReleaseDate;
// ImagePath is tri-state: null = no change, "" = clear, value = set.
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
string Artist,
string? Album,
string? Genre,
string? Description,
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
+4 -2
View File
@@ -57,6 +57,7 @@ public class UnifiedTrackService
string artist,
string? album,
string? genre,
string? description,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
@@ -106,8 +107,8 @@ public class UnifiedTrackService
// Resolve the release FK before persisting the track. An upload with an album lands on the
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
// on the release, not the track.
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
{
@@ -116,6 +117,7 @@ public class UnifiedTrackService
Title = album,
Artist = artist,
Genre = genre,
Description = description,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
Medium = medium,
@@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(100)
.HasColumnName("genre");
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
builder.Property(e => e.Description)
.HasMaxLength(4000)
.HasColumnName("description");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
@@ -0,0 +1,308 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260616035252_AddReleaseDescription")]
partial class AddReleaseDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "release",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "release");
}
}
}
@@ -90,6 +90,11 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
+2
View File
@@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Title = entity.Title,
Artist = entity.Artist,
Genre = entity.Genre,
Description = entity.Description,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
Medium = entity.Medium,
@@ -55,6 +56,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath,
Medium = dto.Medium,
+1
View File
@@ -281,6 +281,7 @@ public class TrackManager
releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre;
releaseEntity.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.Medium = release.Medium;
@@ -22,6 +22,11 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12">
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
T="string" Label="Description" Lines="4" MaxLength="4000"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@@ -79,6 +84,8 @@
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; }
[Parameter] public string Description { get; set; } = string.Empty;
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
@@ -33,6 +33,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -137,6 +138,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -211,6 +213,7 @@
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
@@ -382,6 +385,7 @@
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
@@ -446,6 +450,7 @@
_artist,
album,
genre,
description,
releaseDate,
imagePathForUpdate,
_releaseType,
@@ -480,6 +485,7 @@
_artist,
album,
genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -508,6 +514,7 @@
_artist,
album,
genre,
description,
releaseDate,
linkPath,
_releaseType,
@@ -21,6 +21,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -126,6 +127,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -342,6 +344,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -369,6 +372,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
+4 -1
View File
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
string artist,
string? album,
string? genre,
string? description,
string? releaseDate,
string? originalFileName,
long createdByUserId,
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(artist), "artist");
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
public async Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
@@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService
artist,
album,
genre,
description,
releaseDate,
imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
+2 -1
View File
@@ -30,6 +30,7 @@ public interface ICmsTrackService
string artist,
string? album,
string? genre,
string? description,
string? releaseDate,
string? originalFileName,
long createdByUserId,
@@ -85,7 +86,7 @@ public interface ICmsTrackService
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
+1
View File
@@ -12,6 +12,7 @@ public class ReleaseDto : BaseModel
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string? Genre { get; set; }
public string? Description { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
+4
View File
@@ -15,6 +15,10 @@ public class ReleaseEntity : BaseEntity, IEntity
public required string Title { get; set; }
public required string Artist { get; set; }
public string? Genre { get; set; }
// Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it
// lives on the base table alongside Genre rather than in a per-medium satellite. Plain text,
// max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL.
public string? Description { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
+76
View File
@@ -161,6 +161,82 @@ public class MediumWritePathTests
Assert.That(dto.ReleaseType, Is.Null);
}
// 11.G — Description round-trips through both converter directions verbatim (no medium dance,
// unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back.
[Test]
public void Convert_Description_RoundTripsBothDirections()
{
const string prose = "A late-night set\nrecorded at the Vault.";
var entity = new ReleaseEntity
{
Title = "Live at the Vault", Artist = "Artist A",
Medium = ReleaseMedium.Session, Description = prose,
};
var dto = TrackConverter.Convert(entity);
Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description");
var back = TrackConverter.Convert(dto);
Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description");
}
// 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL).
[Test]
public void Convert_NullDescription_RoundTripsAsNull()
{
var entity = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Description = null };
var dto = TrackConverter.Convert(entity);
Assert.That(dto.Description, Is.Null);
var back = TrackConverter.Convert(dto);
Assert.That(back.Description, Is.Null);
}
// 11.G — Description rides the release-cardinal write channel onto the persisted release row,
// exactly as Genre does. FindOrCreateRelease is the upload-path projection point.
[Test]
public async Task FindOrCreateRelease_NewRelease_PersistsDescription()
{
const string prose = "Three cuts pressed for the summer.";
var manager = CreateManager(CreateRepository());
var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut);
data.Description = prose;
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Description, Is.EqualTo(prose));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
// 11.C — editing a track's linked release sets the Description on the persisted release row,
// mirroring the PUT api/track/meta apply (release.Description = request.Description).
[Test]
public async Task Update_SetsReleaseDescription_PersistsDescription()
{
const string prose = "Now with a proper blurb.";
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
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!.Description = prose;
var result = await manager.Update(loaded);
Assert.That(result.Success, Is.True);
var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
// 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]