Merge upload-duplicate-detection into dev (block duplicate-release uploads by title+artist)

This commit is contained in:
daniel-c-harvey
2026-06-19 16:22:28 -04:00
12 changed files with 588 additions and 51 deletions
+3
View File
@@ -28,6 +28,9 @@
</ItemGroup>
<ItemGroup>
<!-- Referenced for UnifiedTrackService — the dual-database upload orchestrator whose create-path
duplicate guard and within-batch attach path are exercised in UploadDuplicateDetectionTests. -->
<ProjectReference Include="..\DeepDrftAPI\DeepDrftAPI.csproj" />
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain
+9 -9
View File
@@ -60,9 +60,9 @@ public class MediumWritePathTests
"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));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
@@ -75,7 +75,7 @@ public class MediumWritePathTests
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));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
@@ -87,7 +87,7 @@ public class MediumWritePathTests
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));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
@@ -105,10 +105,10 @@ public class MediumWritePathTests
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");
Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
@@ -207,9 +207,9 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Description, Is.EqualTo(prose));
Assert.That(result.Value.Release.Description, Is.EqualTo(prose));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
@@ -0,0 +1,292 @@
using System.Text;
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
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 Microsoft.Extensions.Options;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Server-backstop coverage for upload duplicate detection. Drives the full
/// <see cref="UnifiedTrackService.UploadAsync"/> dual-database write over a real temp-isolated
/// <see cref="FileDb"/> vault and an EF in-memory <see cref="DeepDrftContext"/>, so the create-path
/// duplicate block, the within-batch attach path, and the existing single-track cardinality rule are
/// all asserted against the same orchestrator the controller calls.
///
/// The rule under test: a (title, artist) that pre-existed the submit is blocked on the CREATE path
/// (no releaseId), but the within-batch multi-track Cut still succeeds because rows 2..N pass the
/// release id row 1 created (ATTACH path) and so skip the duplicate lookup entirely.
/// </summary>
[TestFixture]
public class UploadDuplicateDetectionTests
{
private string _testDir = string.Empty;
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "UploadDuplicateDetectionTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private TrackManager CreateManager()
{
var repository = new TrackRepository(
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
return new TrackManager(
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
}
private async Task<UnifiedTrackService> CreateUnifiedServiceAsync(ITrackService sqlTrackService)
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var content = new TrackContentService(
fileDatabase!, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
return new UnifiedTrackService(
content, sqlTrackService, fileDatabase!, waveforms,
NullLogger<UnifiedTrackService>.Instance);
}
private async Task<string> WriteWavAsync(double durationSeconds)
{
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(path, BuildMinimalPcmWav(durationSeconds));
return path;
}
private Task<ResultContainer<TrackDto>> UploadAsync(
UnifiedTrackService service, string tempPath, string trackName, string artist,
string? album, ReleaseMedium medium, long? releaseId)
=> service.UploadAsync(
tempPath, trackName, artist, album,
genre: null, description: null, releaseDate: null, createdByUserId: 1,
originalFileName: null, releaseType: ReleaseType.Single, medium: medium,
trackNumber: 1, releaseId: releaseId, ct: default);
// CREATE path: a brand-new single-track Mix succeeds (no pre-existing (title, artist)).
[Test]
public async Task UploadAsync_NewSingleTrackRelease_Succeeds()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Sunset Set", "DJ B", "Sunset Set", ReleaseMedium.Mix, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Not.Null);
}
// CREATE path: uploading a (title, artist) that already exists is blocked with the duplicate marker
// (which the controller maps to 409), for ANY medium — here a Cut.
[Test]
public async Task UploadAsync_DuplicateTitleArtist_IsBlockedWithDuplicateMarker()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True, "the first create must succeed");
// Second submit, same (title, artist), no releaseId → CREATE path → duplicate block.
var duplicate = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(duplicate.Success, Is.False);
var message = duplicate.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
Assert.That(message, Does.Contain("Studio Album"), "the block message names the existing release");
}
// The crux regression guard: a within-batch multi-track Cut. Row 1 CREATEs the release; row 2 passes
// row 1's ReleaseId (ATTACH path) and must succeed — the within-batch release is NOT a pre-existing
// duplicate. Both tracks end up on the same release.
[Test]
public async Task UploadAsync_WithinBatchMultiTrackCut_AttachesAndSucceeds()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var row1 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId: null);
Assert.That(row1.Success, Is.True, "row 1 creates the release");
var releaseId = row1.Value!.ReleaseId;
Assert.That(releaseId, Is.Not.Null);
// Row 2 attaches to the just-created release — same (title, artist), but with the explicit id.
var row2 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId);
Assert.That(row2.Success, Is.True, row2.Messages.FirstOrDefault()?.Message);
Assert.That(row2.Value!.ReleaseId, Is.EqualTo(releaseId), "row 2 lands on the same release row 1 created");
var peek = (await ((ITrackService)manager).GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
Assert.That(peek.TrackCount, Is.EqualTo(2), "both within-batch tracks are on the one release");
}
// The existing single-track cardinality rule still fires on the attach path: a Session already
// holding its one track rejects a second add with the cardinality marker (controller → 409). This
// is reachable here only via an explicit releaseId, since a no-id second submit is the duplicate path.
[Test]
public async Task UploadAsync_SecondTrackOnSingleTrackRelease_IsBlockedWithCardinalityMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Live Set", "DJ A", "Live Set", ReleaseMedium.Session, releaseId: null);
Assert.That(first.Success, Is.True);
var releaseId = first.Value!.ReleaseId;
// A second track aimed at the same single-track Session via its id → cardinality rejection.
var second = await UploadAsync(
service, await WriteWavAsync(2.0), "Second Take", "DJ A", "Live Set", ReleaseMedium.Session, releaseId);
Assert.That(second.Success, Is.False);
var message = second.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.CardinalityViolationMarker));
}
// ATTACH anti-forgery guard: when the caller supplies a releaseId that does NOT match the release
// the natural key (title, artist) resolves to, the upload is rejected. Guards against a stale or
// forged releaseId pointing at a different (title, artist) than this row carries.
[Test]
public async Task UploadAsync_AttachWithMismatchedReleaseId_IsRejectedWithDuplicateMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
// Create two separate releases so we have two distinct ids.
var releaseA = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Release A", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseA.Success, Is.True, "release A must be created");
var idA = releaseA.Value!.ReleaseId!.Value;
var releaseB = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseB.Success, Is.True, "release B must be created");
// Try to ATTACH to release A while carrying release B's (title, artist). The natural-key lookup
// resolves to B — id A ≠ B.Id → anti-forgery guard fires.
var forged = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: idA);
Assert.That(forged.Success, Is.False);
var message = forged.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
}
// Loose-track success: an upload with null/whitespace album stays release-less (ReleaseId null).
// Confirms the duplicate guard is correctly bypassed for tracks that carry no album.
[Test]
public async Task UploadAsync_NullAlbum_SucceedsAsLooseTrack()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Standalone Cut", "DJ Solo", album: null, ReleaseMedium.Cut, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Null, "a null-album track must stay a loose track with no release");
}
// Case-sensitivity caveat: the assertion below verifies ordinal == equality as implemented by the
// EF in-memory provider (which evaluates LINQ predicates in-process). The deployed PostgreSQL
// instance may use a different column collation (e.g. case-insensitive) — production case-sensitivity
// depends on the collation of the `release` table's `title` and `artist` columns, not on this test.
// Matching semantics: GetReleaseByTitleAndArtist (the read both the pre-flight and the create-path
// duplicate guard use) is exact — a case difference is NOT a match, so it does not trip the block.
// This asserts the pre-flight and the create path agree by using the one shared read.
[Test]
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicateOnInMemoryProvider()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True);
// Different case → not the same natural key under the in-memory provider's ordinal == →
// admitted as a new release. Production outcome depends on PostgreSQL column collation.
var differentCase = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "STUDIO ALBUM", ReleaseMedium.Cut, releaseId: null);
Assert.That(differentCase.Success, Is.True, differentCase.Messages.FirstOrDefault()?.Message);
}
// Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale square
// wave (non-silent so the loudness algorithm yields a real envelope). Same layout as
// TrackReplaceAudioTests / WaveformProfileServiceTests.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}