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; /// /// Server-backstop coverage for upload duplicate detection. Drives the full /// dual-database write over a real temp-isolated /// vault and an EF in-memory , 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. /// [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() .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>.Instance); return new TrackManager( repository, NullLogger>.Instance); } private async Task 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.Instance); return new UnifiedTrackService( content, sqlTrackService, fileDatabase!, waveforms, NullLogger.Instance); } private async Task WriteWavAsync(double durationSeconds) { var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); await File.WriteAllBytesAsync(path, BuildMinimalPcmWav(durationSeconds)); return path; } private Task> 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(); } }