295 lines
13 KiB
C#
295 lines
13 KiB
C#
using DeepDrftContent;
|
|
using DeepDrftContent.Constants;
|
|
using DeepDrftContent.FileDatabase.Models;
|
|
using DeepDrftContent.Processors;
|
|
using DeepDrftContent.Processors.Opus;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the Phase 18.2 format resolution + sidecar lookup seam
|
|
/// (<see cref="TrackFormatResolver"/>) over a real <see cref="FileDb"/>. They exercise the four
|
|
/// resolution branches the brief specifies — lossless, Opus hit, the C2 Opus→lossless fallback, and
|
|
/// the unknown-track miss — plus sidecar hit/miss. Artifacts are seeded into the vaults exactly as
|
|
/// 18.1's <see cref="OpusTranscodeService"/> stores them (Opus audio under the bare EntryKey, sidecar
|
|
/// under the <c>-sidecar</c>-suffixed key, the source in the <c>tracks</c> vault), so the test is faithful
|
|
/// to the real storage convention rather than a stand-in.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class TrackFormatResolverTests
|
|
{
|
|
private string _testDir = string.Empty;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_testDir = Path.Combine(Path.GetTempPath(), "TrackFormatResolverTests", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(_testDir);
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
try { Directory.Delete(_testDir, recursive: true); }
|
|
catch { /* Best-effort cleanup — ignore failures */ }
|
|
}
|
|
|
|
private static TrackFormatResolver CreateResolver(FileDb fileDatabase)
|
|
{
|
|
// 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);
|
|
return new TrackFormatResolver(
|
|
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)
|
|
{
|
|
await db.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
|
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
|
var audio = new AudioBinary(new AudioBinaryParams(bytes, bytes.Length, extension, 12.0, 1411));
|
|
var ok = await db.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audio);
|
|
Assert.That(ok, Is.True, "source seed must succeed");
|
|
return bytes;
|
|
}
|
|
|
|
// 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);
|
|
|
|
var opusBytes = new byte[] { 9, 9, 9 };
|
|
var opusAudio = new AudioBinary(new AudioBinaryParams(
|
|
opusBytes, opusBytes.Length, OggOpusConstants.OpusExtension, 12.0, 320));
|
|
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(
|
|
sidecarBytes, sidecarBytes.Length, OggOpusConstants.SidecarExtension));
|
|
var sidecarOk = await db.RegisterResourceAsync(
|
|
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar);
|
|
Assert.That(sidecarOk, Is.True, "sidecar seed must succeed");
|
|
return sidecarBytes;
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResolveAsync_Lossless_ReturnsSourceArtifactWithItsRealMime()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
const string entryKey = "lossless-track";
|
|
// A FLAC source — proves the lossless branch does NOT assume WAV: the content-type tracks the
|
|
// stored extension's MIME, not a hard-coded audio/wav.
|
|
var bytes = await SeedSourceAsync(db, entryKey, ".flac");
|
|
var resolver = CreateResolver(db);
|
|
|
|
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Lossless);
|
|
|
|
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.DidFallBack(AudioFormat.Lossless), Is.False);
|
|
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(bytes));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResolveAsync_OpusWhenArtifactExists_ReturnsOpusWithOggContentType()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
const string entryKey = "opus-track";
|
|
await SeedSourceAsync(db, entryKey, ".wav");
|
|
var (opusBytes, _) = await SeedOpusAsync(db, entryKey);
|
|
var resolver = CreateResolver(db);
|
|
|
|
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus);
|
|
|
|
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.DidFallBack(AudioFormat.Opus), Is.False);
|
|
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(opusBytes));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResolveAsync_OpusWhenNoArtifact_FallsBackToLosslessNeverNull()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
const string entryKey = "no-opus-track";
|
|
// Source exists; no Opus artifact has been derived. The C2 rule: degrade to lossless, never 404.
|
|
var bytes = await SeedSourceAsync(db, entryKey, ".wav");
|
|
var resolver = CreateResolver(db);
|
|
|
|
var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus);
|
|
|
|
Assert.That(resolved, Is.Not.Null, "Opus absence must degrade to lossless, not null/404");
|
|
Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless),
|
|
"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.DidFallBack(AudioFormat.Opus), Is.True);
|
|
Assert.That(await ReadAllAsync(resolved.Stream), Is.EqualTo(bytes));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResolveAsync_UnknownTrack_ReturnsNull()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
var resolver = CreateResolver(db);
|
|
|
|
// No source at all — the one case the caller maps to 404. Holds for both requested formats:
|
|
// Opus falls back to lossless, finds nothing, and returns null too.
|
|
Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Lossless), Is.Null);
|
|
Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Opus), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetOpusSidecarAsync_WhenPresent_ReturnsBytes()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
const string entryKey = "sidecar-track";
|
|
var (_, sidecarBytes) = await SeedOpusAsync(db, entryKey);
|
|
var resolver = CreateResolver(db);
|
|
|
|
var bytes = await resolver.GetOpusSidecarAsync(entryKey);
|
|
|
|
Assert.That(bytes, Is.Not.Null);
|
|
Assert.That(bytes, Is.EqualTo(sidecarBytes));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetOpusSidecarAsync_WhenAbsent_ReturnsNull()
|
|
{
|
|
var db = (await FileDb.FromAsync(_testDir))!;
|
|
var resolver = CreateResolver(db);
|
|
|
|
// No Opus artifacts derived for this track — the sidecar lookup misses to null, not an exception.
|
|
var bytes = await resolver.GetOpusSidecarAsync("no-sidecar-track");
|
|
|
|
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");
|
|
}
|
|
}
|