using System.Text; using DeepDrftAPI.Services; using DeepDrftContent; using DeepDrftContent.Processors; using DeepDrftContent.Processors.Opus; using DeepDrftData; using DeepDrftModels.DTOs; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Models.Common; using NetBlocks.Models; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; namespace DeepDrftTests; /// /// Confirms the Phase 18.5 acceptance point that /// regenerates the Opus artifact: a replace must schedule a background Opus re-derive for the track, because /// the stale Opus no longer matches the new source bytes (the same reason it regenerates the waveform datums /// and re-derives duration). The enqueue was wired in 18.1; this test pins it so a future refactor cannot /// silently drop it, leaving a track serving Opus that does not match its lossless source. /// /// The vault + content + waveform collaborators are real (over a temp-dir ); the SQL /// service is a focused fake. The fake is used here rather than the in-memory EF store because the replace /// path's duration write goes through SetDurationExecuteUpdateAsync, which the EF in-memory /// provider does not support — the fake lets the orchestration run to the post-write enqueue under test. /// [TestFixture] public class ReplaceAudioOpusRegenTests { private string _testDir = string.Empty; [SetUp] public void SetUp() { _testDir = Path.Combine(Path.GetTempPath(), "ReplaceAudioOpusRegenTests", Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); } [TearDown] public void TearDown() { try { Directory.Delete(_testDir, recursive: true); } catch { /* Best-effort cleanup — ignore failures */ } } // A focused ITrackService fake: only GetById (returns the seeded track) and SetDuration (records the // write and reports success) are meaningful — the two members the replace path calls. Everything else // throws, documenting that the replace orchestration touches nothing else on the SQL boundary. private sealed class FakeTrackService : ITrackService { private readonly TrackDto _track; public double? LastDurationWritten { get; private set; } public FakeTrackService(TrackDto track) => _track = track; public Task> GetById(long id) => Task.FromResult(ResultContainer.CreatePassResult(id == _track.Id ? _track : null)); public Task> SetDuration(long id, double durationSeconds, CancellationToken ct = default) { LastDurationWritten = durationSeconds; return Task.FromResult(ResultContainer.CreatePassResult(1)); } // Unused by the replace path — fail loudly if the orchestration ever reaches them. public Task> GetByEntryKey(string entryKey) => throw new NotSupportedException(); public Task> GetRandom(CancellationToken ct = default) => throw new NotSupportedException(); public Task>> GetAll() => throw new NotSupportedException(); public Task>> GetPaged(int p, int s, string? c, bool d, TrackFilter? f = null, CancellationToken ct = default) => throw new NotSupportedException(); public Task>> GetReleases(CancellationToken ct = default) => throw new NotSupportedException(); public Task>> GetDistinctGenres(CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetHomeStats(CancellationToken ct = default) => throw new NotSupportedException(); public Task>> GetTracksMissingDuration(CancellationToken ct = default) => throw new NotSupportedException(); public Task> UpdateDuration(long id, double d, CancellationToken ct = default) => throw new NotSupportedException(); public Task> FindOrCreateRelease(string t, string a, ReleaseDto r, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetReleaseByTitleAndArtist(string t, string a, CancellationToken ct = default) => throw new NotSupportedException(); public Task> Create(TrackDto t) => throw new NotSupportedException(); public Task> Update(TrackDto t) => throw new NotSupportedException(); public Task Delete(long id) => throw new NotSupportedException(); public Task DeleteRelease(long id, CancellationToken ct = default) => throw new NotSupportedException(); public Task> CountLiveTracksByRelease(long releaseId, CancellationToken ct = default) => throw new NotSupportedException(); } [Test] public async Task ReplaceAudio_EnqueuesOpusRegen_ForTheReplacedTrack() { 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); var resolver = new TrackFormatResolver( fileDatabase!, content, NullLogger.Instance); var queue = new NoOpOpusTranscodeQueue(); // Seed the source WAV in the vault and point a fake SQL row at the same EntryKey. var originalPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); await File.WriteAllBytesAsync(originalPath, BuildMinimalPcmWav(2.0)); var unpersisted = await content.AddTrackAsync(originalPath, "Original", "Artist"); Assert.That(unpersisted, Is.Not.Null); const long trackId = 42; var sql = new FakeTrackService(new TrackDto { Id = trackId, EntryKey = unpersisted!.EntryKey, TrackName = "Original" }); var service = new UnifiedTrackService( content, sql, fileDatabase!, waveforms, queue, resolver, NullLogger.Instance); // Replace the audio with a longer take. var replacementPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); await File.WriteAllBytesAsync(replacementPath, BuildMinimalPcmWav(6.0)); var result = await service.ReplaceAudioAsync(trackId, replacementPath, CancellationToken.None); Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message); Assert.Multiple(() => { Assert.That(queue.Enqueued, Does.Contain(unpersisted.EntryKey), "a replace must schedule an Opus re-derive so the artifact tracks the new source"); Assert.That(sql.LastDurationWritten, Is.GreaterThan(0), "the replace must also write the new duration (the enqueue follows a successful duration write)"); }); } // Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites. 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(); } }