Stream Opus/derived read path: serve from seekable disk FileStream, never a whole-file byte[]; HasOpusAsync is index-only
This commit is contained in:
@@ -26,10 +26,10 @@ namespace DeepDrftTests;
|
||||
/// required to assert the delivery contract.
|
||||
///
|
||||
/// The Range→206 contract is asserted at the load-bearing seam: ASP.NET performs the actual byte-slicing
|
||||
/// for any <see cref="FileResult"/> whose <see cref="FileResult"/>.EnableRangeProcessing is true. The lossless
|
||||
/// path proves this via the disk-stream <see cref="FileStreamResult"/>; the resolved Opus path via the
|
||||
/// in-memory <see cref="FileContentResult"/> — both must report range processing enabled, the explicit fix
|
||||
/// the 18.2 reviewer flagged for the byte[] path.
|
||||
/// for any <see cref="FileResult"/> whose <see cref="FileResult"/>.EnableRangeProcessing is true over a
|
||||
/// seekable stream. Both the lossless path AND the resolved Opus path now serve a disk-backed
|
||||
/// <see cref="FileStreamResult"/> (read-path streaming — no whole-file byte[]); both must report range
|
||||
/// processing enabled, and the FileStream is seekable, so an incoming Range yields a 206 slice.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TrackFormatDeliveryTests
|
||||
@@ -59,13 +59,14 @@ public class TrackFormatDeliveryTests
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: "opus");
|
||||
|
||||
var file = result as FileContentResult;
|
||||
Assert.That(file, Is.Not.Null, "Opus delivery serves an in-memory byte[] (FileContentResult)");
|
||||
var file = result as FileStreamResult;
|
||||
Assert.That(file, Is.Not.Null, "Opus delivery streams from disk (FileStreamResult), not a byte[]");
|
||||
var bytes = await ReadAllAsync(file!.FileStream);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type");
|
||||
Assert.That(file.FileContents, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source");
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus byte[] path");
|
||||
Assert.That(file.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type");
|
||||
Assert.That(bytes, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source");
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus stream path");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,12 +81,13 @@ public class TrackFormatDeliveryTests
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: "opus");
|
||||
|
||||
var file = result as FileContentResult;
|
||||
Assert.That(file, Is.Not.Null, "The fallback still serves resolved bytes via the byte[] path");
|
||||
var file = result as FileStreamResult;
|
||||
Assert.That(file, Is.Not.Null, "The fallback still streams resolved bytes from disk (FileStreamResult)");
|
||||
var bytes = await ReadAllAsync(file!.FileStream);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME");
|
||||
Assert.That(file.FileContents, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes");
|
||||
Assert.That(file.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME");
|
||||
Assert.That(bytes, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes");
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing stays enabled on the fallback path too");
|
||||
});
|
||||
}
|
||||
@@ -167,6 +169,18 @@ public class TrackFormatDeliveryTests
|
||||
|
||||
private byte[] _sourceWav = [];
|
||||
|
||||
// Drains a FileStreamResult's disk-backed stream to a byte[] and disposes it (read-path streaming serves
|
||||
// an open FileStream, not a buffered byte[]). Disposing also releases the handle before temp-dir teardown.
|
||||
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
||||
{
|
||||
await using (stream)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FileDb> FreshDbAsync()
|
||||
{
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
|
||||
@@ -39,8 +39,8 @@ public class TrackFormatResolverTests
|
||||
|
||||
private static TrackFormatResolver CreateResolver(FileDb fileDatabase)
|
||||
{
|
||||
// The resolver only calls GetAudioBinaryAsync (a vault read), which never touches the router —
|
||||
// but TrackContentService requires one, so supply a real router with real processors.
|
||||
// The resolver only opens vault streams / index checks, which never touch the router — but
|
||||
// TrackContentService requires one, so supply a real router with real processors.
|
||||
var router = new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
|
||||
var contentService = new TrackContentService(fileDatabase, router);
|
||||
@@ -48,6 +48,18 @@ public class TrackFormatResolverTests
|
||||
fileDatabase, contentService, NullLogger<TrackFormatResolver>.Instance);
|
||||
}
|
||||
|
||||
// Drains a ResolvedAudio's disk-backed stream to a byte[] and disposes it, so the assertions compare the
|
||||
// bytes actually served (read-path streaming returns an open Stream, not a buffered AudioBinary).
|
||||
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
||||
{
|
||||
await using (stream)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Seeds a source artifact in the tracks vault with the given extension, mirroring how the upload path
|
||||
// stores the original bytes (WAV/MP3/FLAC). Returns the bytes for downstream identity assertions.
|
||||
private static async Task<byte[]> SeedSourceAsync(FileDb db, string entryKey, string extension)
|
||||
@@ -63,6 +75,14 @@ public class TrackFormatResolverTests
|
||||
// Seeds the Opus audio + sidecar in the track-opus vault exactly as OpusTranscodeService does:
|
||||
// audio under OpusAudioKey (the bare EntryKey) with the .opus extension, sidecar under OpusSidecarKey.
|
||||
private static async Task<(byte[] opus, byte[] sidecar)> SeedOpusAsync(FileDb db, string entryKey)
|
||||
{
|
||||
var opusBytes = await SeedOpusAudioOnlyAsync(db, entryKey);
|
||||
var sidecarBytes = await SeedOpusSidecarOnlyAsync(db, entryKey);
|
||||
return (opusBytes, sidecarBytes);
|
||||
}
|
||||
|
||||
// Seeds only the Opus audio half (no sidecar) — a half-derived track, used to prove HasOpusAsync rejects it.
|
||||
private static async Task<byte[]> SeedOpusAudioOnlyAsync(FileDb db, string entryKey)
|
||||
{
|
||||
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
|
||||
@@ -72,6 +92,13 @@ public class TrackFormatResolverTests
|
||||
var audioOk = await db.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opusAudio);
|
||||
Assert.That(audioOk, Is.True, "opus audio seed must succeed");
|
||||
return opusBytes;
|
||||
}
|
||||
|
||||
// Seeds only the sidecar half (no Opus audio) — the other half-derived case HasOpusAsync must reject.
|
||||
private static async Task<byte[]> SeedOpusSidecarOnlyAsync(FileDb db, string entryKey)
|
||||
{
|
||||
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
|
||||
var sidecarBytes = new byte[] { 7, 7, 7, 7 };
|
||||
var sidecar = new MediaBinary(new MediaBinaryParams(
|
||||
@@ -79,8 +106,7 @@ public class TrackFormatResolverTests
|
||||
var sidecarOk = await db.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar);
|
||||
Assert.That(sidecarOk, Is.True, "sidecar seed must succeed");
|
||||
|
||||
return (opusBytes, sidecarBytes);
|
||||
return sidecarBytes;
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -98,8 +124,8 @@ public class TrackFormatResolverTests
|
||||
Assert.That(resolved, Is.Not.Null);
|
||||
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless));
|
||||
Assert.That(resolved.ContentType, Is.EqualTo("audio/flac"));
|
||||
Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes));
|
||||
Assert.That(resolved.DidFallBack(AudioFormat.Lossless), Is.False);
|
||||
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(bytes));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -116,8 +142,8 @@ public class TrackFormatResolverTests
|
||||
Assert.That(resolved, Is.Not.Null);
|
||||
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Opus));
|
||||
Assert.That(resolved.ContentType, Is.EqualTo("audio/ogg"));
|
||||
Assert.That(resolved.Audio.Buffer, Is.EqualTo(opusBytes));
|
||||
Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.False);
|
||||
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(opusBytes));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -136,8 +162,8 @@ public class TrackFormatResolverTests
|
||||
"the resolved format must reflect what was actually returned");
|
||||
Assert.That(resolved.ContentType, Is.EqualTo("audio/wav"),
|
||||
"a fallback returns the lossless content-type so the decoder picks the right decoder");
|
||||
Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes));
|
||||
Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.True);
|
||||
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(bytes));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -177,4 +203,92 @@ public class TrackFormatResolverTests
|
||||
|
||||
Assert.That(bytes, Is.Null);
|
||||
}
|
||||
|
||||
// --- HasOpusAsync completeness matrix (both halves required) ---
|
||||
|
||||
[Test]
|
||||
public async Task HasOpusAsync_WhenAudioAndSidecarPresent_ReturnsTrue()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
const string entryKey = "complete-opus";
|
||||
await SeedOpusAsync(db, entryKey);
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
Assert.That(await resolver.HasOpusAsync(entryKey), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HasOpusAsync_WhenSidecarMissing_ReturnsFalse()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
const string entryKey = "audio-only-opus";
|
||||
await SeedOpusAudioOnlyAsync(db, entryKey);
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
Assert.That(await resolver.HasOpusAsync(entryKey), Is.False,
|
||||
"audio without the seek/setup sidecar is unseekable — counts as not-having-Opus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HasOpusAsync_WhenAudioMissing_ReturnsFalse()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
const string entryKey = "sidecar-only-opus";
|
||||
await SeedOpusSidecarOnlyAsync(db, entryKey);
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
Assert.That(await resolver.HasOpusAsync(entryKey), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HasOpusAsync_WhenNeitherPresent_ReturnsFalse()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
// No track-opus vault at all (and no entries) — must miss to false, not throw.
|
||||
Assert.That(await resolver.HasOpusAsync("ghost"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HasOpusAsync_IsIndexOnly_DoesNotReadFileBodies()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
const string entryKey = "indexed-but-bodiless";
|
||||
await SeedOpusAsync(db, entryKey);
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
// Delete the backing files but leave the index entries intact. An index-only existence check still
|
||||
// reports true; a body-loading implementation would now miss. This is the load-bearing assertion
|
||||
// that HasOpusAsync never streams a file body (the opus-status loop runs it over the whole catalogue).
|
||||
var vaultDir = Path.Combine(_testDir, VaultConstants.TrackOpus);
|
||||
foreach (var file in Directory.EnumerateFiles(vaultDir))
|
||||
{
|
||||
if (Path.GetFileName(file) != "index")
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
Assert.That(await resolver.HasOpusAsync(entryKey), Is.True,
|
||||
"HasOpusAsync must be index-only: true even when no file bodies are present");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResolveAsync_DisposingResult_DisposesUnderlyingStream()
|
||||
{
|
||||
var db = (await FileDb.FromAsync(_testDir))!;
|
||||
const string entryKey = "dispose-track";
|
||||
await SeedSourceAsync(db, entryKey, ".wav");
|
||||
var resolver = CreateResolver(db);
|
||||
|
||||
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Lossless);
|
||||
Assert.That(resolved, Is.Not.Null);
|
||||
|
||||
var stream = resolved!.Stream;
|
||||
Assert.That(stream.CanRead, Is.True, "a freshly resolved handle holds an open stream");
|
||||
|
||||
await resolved.DisposeAsync();
|
||||
|
||||
Assert.That(stream.CanRead, Is.False,
|
||||
"disposing ResolvedAudio must dispose the underlying FileStream so a handle never leaks on the throw path");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user