Files
deepdrft/DeepDrftTests/TrackFormatDeliveryTests.cs

304 lines
13 KiB
C#

using System.Text;
using DeepDrftAPI.Controllers;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ContentTrackService = DeepDrftContent.TrackContentService;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Delivery-layer tests for the Phase 18.3 <c>?format=</c> stream selector and the Opus seek/setup sidecar
/// endpoint on <see cref="TrackController"/>. These exercise the real <see cref="FileDb"/>, the real
/// <see cref="TrackContentService"/>, and the real <see cref="TrackFormatResolver"/> over temp-directory
/// vaults — the same integration posture as <see cref="TrackReplaceAudioTests"/>.
///
/// The SQL-only collaborators (<c>UnifiedTrackService</c>, <c>ITrackService</c>) are passed as null: the
/// actions under test (<see cref="TrackController.GetTrack"/>, <see cref="TrackController.GetOpusSeekData"/>)
/// only touch the FileDatabase + resolver path, never the SQL services, so standing up a database is not
/// 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 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
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "TrackFormatDeliveryTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
// --- Format resolution at the endpoint ---
[Test]
public async Task GetTrack_FormatOpus_WhenOpusArtifactPresent_ServesOpusBytesAndOggContentType()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "opus");
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(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");
});
}
// --- The C2 fallback ---
[Test]
public async Task GetTrack_FormatOpus_WhenNoOpusArtifact_FallsBackToLosslessBytesAndContentType()
{
// No Opus artifact stored — the resolver degrades to lossless (C2): the listener still gets audio,
// never a 404 or silence, and the content-type reports the lossless format actually served.
var (controller, entryKey) = await SeedAsync(withOpus: false, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "opus");
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(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");
});
}
[Test]
public async Task GetTrack_NoFormatParam_ServesLosslessDiskStream_ByteIdenticalToPrePhase18()
{
// The no-format path must be byte-identical to today: the zero-copy disk-stream FileStreamResult,
// NOT the resolver's in-memory byte[] path (which would force the whole source into memory).
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: null);
var file = result as FileStreamResult;
Assert.That(file, Is.Not.Null, "Lossless delivery streams from disk (FileStreamResult), not a byte[]");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"));
Assert.That(file.EnableRangeProcessing, Is.True, "Range→206 must work on the lossless disk-stream path");
});
}
[Test]
public async Task GetTrack_FormatLossless_TakesTheLosslessDiskStreamPath()
{
// An explicit format=lossless must behave exactly like no param — the disk-stream path, never Opus.
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetTrack(entryKey, format: "lossless");
Assert.That(result, Is.InstanceOf<FileStreamResult>(),
"format=lossless must take the disk-stream path even when an Opus artifact exists");
}
[Test]
public async Task GetTrack_FormatOpus_WhenTrackDoesNotExist_Returns404()
{
var controller = BuildController(await FreshDbAsync());
var result = await controller.GetTrack("no-such-track", format: "opus");
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
"When even the lossless source is missing, the Opus request 404s (no audio at all)");
}
// --- Sidecar 200 / 404 ---
[Test]
public async Task GetOpusSeekData_WhenSidecarPresent_Returns200WithRawBytes()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: true);
var result = await controller.GetOpusSeekData(entryKey);
var file = result as FileContentResult;
Assert.That(file, Is.Not.Null, "A stored sidecar is served as raw bytes");
Assert.Multiple(() =>
{
Assert.That(file!.ContentType, Is.EqualTo("application/octet-stream"));
Assert.That(file.FileContents, Is.EqualTo(SidecarBytes), "The served bytes must be the stored sidecar blob");
});
}
[Test]
public async Task GetOpusSeekData_WhenNoSidecar_Returns404()
{
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
var result = await controller.GetOpusSeekData(entryKey);
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
"No sidecar → 404, so the client degrades to lossless rather than treating it as an error");
}
// --- Fixtures + helpers ---
private static readonly byte[] OpusBytes = Encoding.ASCII.GetBytes("OggS-fake-opus-payload-for-delivery-test");
private static readonly byte[] SidecarBytes = Encoding.ASCII.GetBytes("setup-header+seek-index-sidecar-blob");
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);
Assert.That(db, Is.Not.Null);
return db!;
}
// Seeds a track's lossless source in the tracks vault and, optionally, its Opus artifact and sidecar in
// the track-opus vault, then returns a controller wired over those real vaults plus the entry key.
private async Task<(TrackController Controller, string EntryKey)> SeedAsync(bool withOpus, bool withSidecar)
{
var db = await FreshDbAsync();
var content = new ContentTrackService(db, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
_sourceWav = BuildMinimalPcmWav(2.0);
await File.WriteAllBytesAsync(wavPath, _sourceWav);
var seeded = await content.AddTrackAsync(wavPath, "Track", "Artist");
Assert.That(seeded, Is.Not.Null);
var entryKey = seeded!.EntryKey;
// GetAudioBinaryAsync re-reads what AddTrackAsync stored, so the bytes we assert the fallback against
// are the exact stored source bytes (the processor may normalize the input WAV before storing).
var storedSource = await content.GetAudioBinaryAsync(entryKey);
Assert.That(storedSource, Is.Not.Null);
_sourceWav = storedSource!.Buffer;
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
if (withOpus)
{
var opus = new AudioBinary(new AudioBinaryParams(OpusBytes, OpusBytes.Length, ".opus", 2.0, 320));
Assert.That(
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
Is.True);
}
if (withSidecar)
{
var sidecar = new MediaBinary(new MediaBinaryParams(SidecarBytes, SidecarBytes.Length, ".opusidx"));
Assert.That(
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
Is.True);
}
return (BuildController(db, content), entryKey);
}
private static TrackController BuildController(FileDb db, ContentTrackService? content = null)
{
content ??= new ContentTrackService(db, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
db, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
var resolver = new TrackFormatResolver(db, content, NullLogger<TrackFormatResolver>.Instance);
// SQL-only collaborators are null: the delivery actions under test never touch them.
var controller = new TrackController(
content,
db,
unifiedService: null!,
sqlTrackService: null!,
waveforms,
resolver,
stagingDirectory: null!,
NullLogger<TrackController>.Instance)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return controller;
}
// 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();
}
}