using System.Text;
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
///
/// Tests for the streamed audio store path (Wave 1 OOM fix): processors emit a
/// plan whose body is written to the vault without ever materializing
/// the whole file in a managed byte[]. Covers passthrough byte-identity (standard-PCM WAV,
/// MP3, FLAC), streamed WAV normalization (EXTENSIBLE), the new streaming vault register round-trip,
/// and the memory-bounding contract of the store primitive (bounded-buffer, sequential, forward-only
/// writes — never a single whole-file write).
///
[TestFixture]
public class AudioStoreStreamingTests
{
private const ushort WaveFormatPcm = 0x0001;
private const ushort WaveFormatExtensible = 0xFFFE;
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "AudioStoreStreamingTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private static AudioProcessorRouter Router() =>
new(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
private static TrackContentService Content(FileDb db) => new(db, Router());
// -- End-to-end store byte-identity (passthrough) -----------------------------------------
[Test]
public async Task StandardPcmWav_StoredByteIdenticalToSource()
{
var source = BuildPcmWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, dataBytes: 200_000);
var path = await WriteAsync(source, ".wav");
var db = await FileDb.FromAsync(_testDir);
var content = Content(db!);
var entity = await content.AddTrackAsync(path, "Track", "Artist");
Assert.That(entity, Is.Not.Null);
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.Buffer, Is.EqualTo(source), "Standard PCM must be stored byte-identical");
Assert.That(stored.Duration, Is.GreaterThan(0.0));
}
[Test]
public async Task Mp3_StoredByteIdenticalToSource_MetadataCorrect()
{
var source = BuildMinimalMp3();
var path = await WriteAsync(source, ".mp3");
var db = await FileDb.FromAsync(_testDir);
var content = Content(db!);
var entity = await content.AddTrackAsync(path, "Track", "Artist");
Assert.That(entity, Is.Not.Null);
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
Assert.Multiple(() =>
{
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.Extension, Is.EqualTo(".mp3"));
Assert.That(stored.Buffer, Is.EqualTo(source), "MP3 must be stored byte-identical (no transcode)");
Assert.That(stored.Bitrate, Is.EqualTo(128));
});
}
[Test]
public async Task Flac_StoredByteIdenticalToSource_MetadataCorrect()
{
var source = BuildMinimalFlac();
var path = await WriteAsync(source, ".flac");
var db = await FileDb.FromAsync(_testDir);
var content = Content(db!);
var entity = await content.AddTrackAsync(path, "Track", "Artist");
Assert.That(entity, Is.Not.Null);
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
Assert.Multiple(() =>
{
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.Extension, Is.EqualTo(".flac"));
Assert.That(stored.Buffer, Is.EqualTo(source), "FLAC must be stored byte-identical (no transcode)");
Assert.That(stored.Duration, Is.GreaterThan(0.0));
});
}
// -- Streamed normalization ---------------------------------------------------------------
[Test]
public async Task ExtensibleFloatWav_StoredAsNormalizedStandardPcm()
{
// A >80 KB float data region forces the streamed transform across multiple bounded chunks.
var floatData = BuildFloatRamp(sampleCount: 40_000); // 160 000 bytes in, 120 000 bytes out (24-bit)
var source = BuildExtensibleWav(channels: 2, sampleRate: 44100, containerBits: 32, validBits: 32,
subFormatTag: 0x0003, sampleData: floatData);
var path = await WriteAsync(source, ".wav");
var db = await FileDb.FromAsync(_testDir);
var content = Content(db!);
var entity = await content.AddTrackAsync(path, "Track", "Artist");
Assert.That(entity, Is.Not.Null);
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
Assert.That(stored, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(BitConverter.ToUInt16(stored!.Buffer, 20), Is.EqualTo(WaveFormatPcm),
"EXTENSIBLE must be normalized to standard PCM (audioFormat = 1)");
Assert.That(BitConverter.ToUInt16(stored.Buffer, 34), Is.EqualTo(24), "Float must normalize to 24-bit");
Assert.That(stored.Buffer.Length, Is.EqualTo(44 + (floatData.Length / 4) * 3),
"Output size = 44-byte header + 3 bytes per float sample");
});
}
// -- Streaming vault register round-trip ---------------------------------------------------
[Test]
public async Task RegisterResourceStreamingAsync_RoundTrips()
{
var db = await FileDb.FromAsync(_testDir);
await db!.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
var payload = Enumerable.Range(0, 50_000).Select(i => (byte)(i % 256)).ToArray();
var meta = MetaDataFactory.CreateAudioMetaData("entry-1", ".wav", 12.5, 1411);
var ok = await db.RegisterResourceStreamingAsync(
VaultConstants.Tracks, "entry-1", meta,
(dest, ct) => dest.WriteAsync(payload, ct).AsTask());
Assert.That(ok, Is.True);
var loaded = await db.LoadResourceAsync(VaultConstants.Tracks, "entry-1");
Assert.Multiple(() =>
{
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.Buffer, Is.EqualTo(payload), "Streamed bytes must round-trip exactly");
Assert.That(loaded.Duration, Is.EqualTo(12.5));
Assert.That(loaded.Bitrate, Is.EqualTo(1411));
});
}
[Test]
public async Task RegisterResourceStreamingAsync_UnknownVault_ReturnsFalse()
{
var db = await FileDb.FromAsync(_testDir);
var meta = MetaDataFactory.CreateAudioMetaData("e", ".wav", 1.0, 1411);
var ok = await db!.RegisterResourceStreamingAsync(
"does-not-exist", "e", meta, (dest, ct) => Task.CompletedTask);
Assert.That(ok, Is.False, "Register into a missing vault must swallow and return false");
}
// -- Memory-bounding of the store primitive -----------------------------------------------
[Test]
public async Task WriteToAsync_StreamsInBoundedSequentialChunks_NotOneWholeFileWrite()
{
// A multi-hundred-KB passthrough file: a buffered implementation would issue one giant write of
// the whole body; the streamed primitive must write in bounded, forward-only chunks.
var source = BuildPcmWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, dataBytes: 600_000);
var path = await WriteAsync(source, ".wav");
var processed = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(processed, Is.Not.Null);
var probe = new BoundedWriteProbeStream();
await processed!.WriteToAsync(probe);
Assert.Multiple(() =>
{
Assert.That(probe.TotalBytes, Is.EqualTo(source.Length), "All bytes must be written");
Assert.That(probe.WriteCount, Is.GreaterThan(1), "Body must be streamed in multiple chunks");
Assert.That(probe.MaxWriteSize, Is.LessThanOrEqualTo(81920),
"No single write may exceed the bounded buffer — i.e. the whole file is never buffered");
});
}
[Test]
public async Task WriteToAsync_NormalizedFloat_StreamsToForwardOnlyStream()
{
// The normalized path seeks the *source* but must only write the destination sequentially.
var floatData = BuildFloatRamp(sampleCount: 30_000);
var source = BuildExtensibleWav(channels: 2, sampleRate: 44100, containerBits: 32, validBits: 32,
subFormatTag: 0x0003, sampleData: floatData);
var path = await WriteAsync(source, ".wav");
var processed = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(processed, Is.Not.Null);
var probe = new BoundedWriteProbeStream();
await processed!.WriteToAsync(probe);
Assert.Multiple(() =>
{
Assert.That(probe.TotalBytes, Is.EqualTo(44 + (floatData.Length / 4) * 3));
Assert.That(probe.WriteCount, Is.GreaterThan(1), "Header + multiple transformed body chunks");
Assert.That(probe.MaxWriteSize, Is.LessThanOrEqualTo(81920));
});
}
// -- atomic-write safety (cancel / fault on replace path) ---------------------------------
///
/// A client disconnect (OperationCanceledException) during the streamed write must leave any
/// pre-existing backing file byte-identical. The atomic temp→rename ordering ensures the rename
/// never happens on an incomplete write, so the original file is never truncated or overwritten.
///
[Test]
public async Task AddEntryStreamingAsync_CancelMidWrite_OriginalBackingFileUnchanged()
{
var vault = await AudioVault.FromAsync(_testDir);
Assert.That(vault, Is.Not.Null);
const string entryId = "replace-target";
var original = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE };
var originalMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 10.0, 1411);
// Write the original entry — this is the backing file that must survive a cancelled replace.
await vault!.AddEntryStreamingAsync(entryId, originalMeta,
async (s, ct) => await s.WriteAsync(original, ct));
var backingPath = Path.Combine(_testDir, "replace-target.wav");
Assert.That(File.Exists(backingPath), Is.True, "Pre-condition: original backing file must exist");
var originalOnDisk = await File.ReadAllBytesAsync(backingPath);
Assert.That(originalOnDisk, Is.EqualTo(original), "Pre-condition: backing file must hold original bytes");
// Attempt a replace whose writeContent cancels after writing only a portion.
using var cts = new CancellationTokenSource();
var replacement = new byte[10_000];
Array.Fill(replacement, (byte)0xFF);
var replaceMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 5.0, 1411);
Assert.ThrowsAsync(async () =>
await vault.AddEntryStreamingAsync(entryId, replaceMeta,
async (s, ct) =>
{
await s.WriteAsync(replacement.AsMemory(0, 100), ct);
await cts.CancelAsync();
ct.ThrowIfCancellationRequested(); // surfaces the cancel from the token
},
cts.Token));
// The original backing file must be byte-identical — no truncation, no partial replacement.
var afterContent = await File.ReadAllBytesAsync(backingPath);
Assert.That(afterContent, Is.EqualTo(original),
"Original backing file must be byte-identical after a cancelled replace");
// No stray temp files should remain in the vault directory.
var tmpFiles = Directory.GetFiles(_testDir, "*.tmp");
Assert.That(tmpFiles, Is.Empty, "Temp file must be cleaned up after cancel");
}
///
/// An I/O fault during the streamed write must leave any pre-existing backing file intact.
/// The atomic temp→rename ordering ensures a faulting write never reaches rename, so the
/// original file is never touched.
///
[Test]
public async Task AddEntryStreamingAsync_FaultMidWrite_OriginalBackingFileUnchanged()
{
var vault = await AudioVault.FromAsync(_testDir);
Assert.That(vault, Is.Not.Null);
const string entryId = "fault-target";
var original = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
var originalMeta = MetaDataFactory.CreateAudioMetaData(entryId, ".wav", 10.0, 1411);
await vault!.AddEntryStreamingAsync(entryId, originalMeta,
async (s, ct) => await s.WriteAsync(original, ct));
var backingPath = Path.Combine(_testDir, "fault-target.wav");
var originalOnDisk = await File.ReadAllBytesAsync(backingPath);
Assert.That(originalOnDisk, Is.EqualTo(original), "Pre-condition: backing file must hold original bytes");
// Attempt a replace whose writeContent throws a simulated I/O fault.
Assert.ThrowsAsync(async () =>
await vault.AddEntryStreamingAsync(entryId, originalMeta,
(_, _) => throw new IOException("Simulated I/O fault")));
// The original backing file must be intact.
var afterContent = await File.ReadAllBytesAsync(backingPath);
Assert.That(afterContent, Is.EqualTo(original),
"Original backing file must be byte-identical after a faulting replace");
var tmpFiles = Directory.GetFiles(_testDir, "*.tmp");
Assert.That(tmpFiles, Is.Empty, "Temp file must be cleaned up after fault");
}
// -- builders -----------------------------------------------------------------------------
private async Task WriteAsync(byte[] bytes, string extension)
{
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + extension);
await File.WriteAllBytesAsync(path, bytes);
return path;
}
private static byte[] BuildPcmWav(int channels, int sampleRate, int bitsPerSample, int dataBytes)
{
var blockAlign = (ushort)(channels * (bitsPerSample / 8));
var byteRate = (uint)(sampleRate * blockAlign);
var data = new byte[dataBytes];
for (var i = 0; i < data.Length; i++) data[i] = (byte)(i % 251);
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(WaveFormatPcm);
w.Write((ushort)channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write((ushort)bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
private static byte[] BuildExtensibleWav(
int channels, int sampleRate, int containerBits, int validBits, ushort subFormatTag, byte[] sampleData)
{
var blockAlign = (ushort)(channels * (containerBits / 8));
var byteRate = (uint)(sampleRate * blockAlign);
const uint fmtChunkSize = 40;
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 + (fmtChunkSize - 16) + sampleData.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(fmtChunkSize);
w.Write(WaveFormatExtensible);
w.Write((ushort)channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write((ushort)containerBits);
w.Write((ushort)22); // cbSize
w.Write((ushort)validBits); // wValidBitsPerSample
w.Write((uint)0); // channel mask
var guid = new byte[16];
guid[0] = (byte)(subFormatTag & 0xFF);
guid[1] = (byte)((subFormatTag >> 8) & 0xFF);
w.Write(guid);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)sampleData.Length);
w.Write(sampleData);
w.Flush();
return ms.ToArray();
}
private static byte[] BuildFloatRamp(int sampleCount)
{
var bytes = new byte[sampleCount * 4];
for (var i = 0; i < sampleCount; i++)
{
var sample = (float)((i % 200) / 200.0 - 0.5); // a deterministic ramp in [-0.5, 0.5)
BitConverter.GetBytes(sample).CopyTo(bytes, i * 4);
}
return bytes;
}
private static byte[] BuildMinimalMp3()
{
// One MPEG1 Layer III CBR frame: 128 kbps, 44.1 kHz, stereo. Body zero-filled (silence).
const int frameSize = 417; // floor(144 * 128000 / 44100)
var buffer = new byte[frameSize];
buffer[0] = 0xFF;
buffer[1] = 0xFB; // sync + MPEG1 + Layer III + no CRC
buffer[2] = (byte)((9 << 4) | (0 << 2)); // bitrate index 9 (128 kbps), sample-rate index 0 (44.1 kHz)
buffer[3] = 0x00; // stereo
return buffer;
}
private static byte[] BuildMinimalFlac()
{
using var ms = new MemoryStream();
ms.Write(Encoding.ASCII.GetBytes("fLaC"));
ms.WriteByte(0x80); // last block, STREAMINFO
ms.WriteByte(0x00);
ms.WriteByte(0x00);
ms.WriteByte(34);
var s = new byte[34];
const int sampleRate = 44100;
const int channels = 2;
const int bitsPerSample = 16;
const long totalSamples = 44100L * 5;
s[10] = (byte)((sampleRate >> 12) & 0xFF);
s[11] = (byte)((sampleRate >> 4) & 0xFF);
var bps = bitsPerSample - 1;
s[12] = (byte)(((sampleRate & 0x0F) << 4) | (((channels - 1) & 0x07) << 1) | ((bps >> 4) & 0x01));
s[13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
s[14] = (byte)((totalSamples >> 24) & 0xFF);
s[15] = (byte)((totalSamples >> 16) & 0xFF);
s[16] = (byte)((totalSamples >> 8) & 0xFF);
s[17] = (byte)(totalSamples & 0xFF);
ms.Write(s);
// Trailing zero bytes standing in for encoded frames (affect only the average-bitrate compute).
ms.Write(new byte[100_000]);
return ms.ToArray();
}
///
/// A write-only, forward-only (non-seekable) stream that records how the store primitive writes:
/// the number of writes, the largest single write, and the total. Proves the body is streamed in
/// bounded, sequential chunks rather than buffered into one whole-file write.
///
private sealed class BoundedWriteProbeStream : Stream
{
public int WriteCount { get; private set; }
public int MaxWriteSize { get; private set; }
public long TotalBytes { get; private set; }
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => TotalBytes;
public override long Position { get => TotalBytes; set => throw new NotSupportedException(); }
private void Record(int count)
{
WriteCount++;
if (count > MaxWriteSize) MaxWriteSize = count;
TotalBytes += count;
}
public override void Write(byte[] buffer, int offset, int count) => Record(count);
public override void Write(ReadOnlySpan buffer) => Record(buffer.Length);
public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
{
Record(buffer.Length);
return ValueTask.CompletedTask;
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
Record(count);
return Task.CompletedTask;
}
public override void Flush() { }
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}