Files
av-parser/tests/AvParser.Infrastructure.Tests/Media/MediaStoreTests.cs
T
Leonid PershinandClaude Opus 5 181f974a37 Add the media store: content-addressed blobs, SQLite index, showcase
First half of replacing the stub text domain with a media collector. Nothing
references this yet - the store is standalone and fully tested before anything
depends on it.

Blobs are addressed by SHA-256 and sharded two levels deep, so the same picture
re-uploaded at a dozen addresses costs one file. Downloads stage in a sibling
temp directory on the same volume and are promoted by rename, which is what
keeps blobs/ free of truncated files: a crash leaves a stray .part that the next
startup sweeps, never a half-image indistinguishable from a real one.

The SQLite index holds provenance separately from content, so purging one source
leaves blobs another source still references - that is what ref_count buys, and
it is recomputed rather than incremented because the item upsert can replace a
row pointing at a different blob. The seen_url journal deliberately outlives a
purge: without that, the next run downloads again exactly what the user just
deleted. Terminal outcomes are split from retryable ones so a flaky network does
not permanently lose content.

The showcase gives every item a dated, named path via hard links - a second name
for one file, not a second file. Hard links are a filesystem privilege rather
than a guarantee, so it degrades to copying and records which it achieved; the
UI has to be able to admit that. Names suggested by the origin are treated as
hostile: only the last path segment survives, Windows device names are pushed
aside, and the extension comes from the sniffed kind, never from the remote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:46:21 +03:00

352 lines
12 KiB
C#

using System.Security.Cryptography;
using System.Text;
using AvParser.Core.Collecting;
using AvParser.Infrastructure.Media;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests.Media;
public sealed class MediaStoreTests : IAsyncLifetime
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N"));
private AppPaths _paths = null!;
private SqliteMediaIndex _index = null!;
private BlobStore _blobs = null!;
private MediaStore _store = null!;
public async ValueTask InitializeAsync()
{
_paths = new AppPaths(_root);
_paths.EnsureCreated();
_index = new SqliteMediaIndex(_paths, NullLogger<SqliteMediaIndex>.Instance);
_blobs = new BlobStore(_paths, NullLogger<BlobStore>.Instance);
_store = Build();
await _store.InitialiseAsync(TestContext.Current.CancellationToken);
}
public ValueTask DisposeAsync()
{
_index.Dispose();
// Pooled SQLite connections keep the file open; without this the delete races the pool.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (Directory.Exists(_root))
{
try
{
Directory.Delete(_root, recursive: true);
}
catch (IOException)
{
// A leaked handle is not worth failing a green test over.
}
}
return ValueTask.CompletedTask;
}
private MediaStore Build(LinkStrategy? hardLink = null) =>
new(
_index,
_blobs,
new ShowcaseLinker(_paths, _blobs, NullLogger<ShowcaseLinker>.Instance, hardLink),
NullLogger<MediaStore>.Instance
);
/// <summary>Writes bytes to a staged temp file and returns the blob describing them.</summary>
private (MediaBlob Blob, string TempPath) Stage(string content, MediaKind kind = MediaKind.Png)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = Convert.ToHexStringLower(SHA256.HashData(bytes));
var temp = _blobs.CreateTempPath();
File.WriteAllBytes(temp, bytes);
return (MediaBlob.Create(hash, kind, bytes.Length), temp);
}
private async Task<CollectedItem> StoreAsync(
string content,
string sourceId = "url-list",
string? url = null,
string? suggestedName = null,
MediaStore? store = null
)
{
var (blob, temp) = Stage(content);
var candidate = new MediaCandidate(new Uri(url ?? $"https://example.test/{content}.png"))
{
SourceId = sourceId,
SuggestedName = suggestedName,
};
var request = new MediaStoreRequest(candidate, blob, temp, "run-1", candidate.Url, 200);
return await (store ?? _store).StoreAsync(request, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Storing_writes_one_blob_and_reports_it_as_new()
{
var item = await StoreAsync("alpha");
item.Status.ShouldBe(CollectStatus.Stored);
File.Exists(_blobs.PathFor(item.Blob)).ShouldBeTrue();
var stats = await _store.GetStatsAsync(TestContext.Current.CancellationToken);
stats.BlobCount.ShouldBe(1);
stats.ItemCount.ShouldBe(1);
}
[Fact]
public async Task The_same_bytes_at_two_addresses_produce_one_blob_and_two_provenance_rows()
{
// The point of hashing the content instead of the address: the web re-uploads constantly.
var first = await StoreAsync("same", url: "https://example.test/a.png");
var second = await StoreAsync("same", url: "https://example.test/b.png");
first.Status.ShouldBe(CollectStatus.Stored);
second.Status.ShouldBe(CollectStatus.Duplicate);
first.Blob.Sha256.ShouldBe(second.Blob.Sha256);
var stats = await _store.GetStatsAsync(TestContext.Current.CancellationToken);
stats.BlobCount.ShouldBe(1);
stats.ItemCount.ShouldBe(2);
}
[Fact]
public async Task Re_collecting_the_same_address_does_not_add_a_second_row()
{
await StoreAsync("alpha", url: "https://example.test/a.png");
await StoreAsync("alpha", url: "https://example.test/a.png");
(await _store.GetStatsAsync(TestContext.Current.CancellationToken)).ItemCount.ShouldBe(1);
}
[Fact]
public async Task A_stored_address_is_journalled_so_a_re_run_can_skip_it()
{
await StoreAsync("alpha", url: "https://example.test/a.png");
var seen = await _store.GetSeenAsync(
"url-list",
["https://example.test/a.png"],
TestContext.Current.CancellationToken
);
seen["https://example.test/a.png"].ShouldBe(SeenOutcome.Stored);
SeenOutcomes.IsTerminal(seen["https://example.test/a.png"]).ShouldBeTrue();
}
[Fact]
public async Task Repeated_failures_at_one_address_count_up_without_becoming_terminal()
{
// A failure describes the moment, not the resource; treating it as final would make a
// flaky network permanently lose content.
for (var attempt = 0; attempt < 3; attempt++)
{
await _store.RecordSeenAsync(
"url-list",
"https://example.test/flaky.png",
SeenOutcome.Failed,
cancellationToken: TestContext.Current.CancellationToken
);
}
var seen = await _store.GetSeenAsync(
"url-list",
["https://example.test/flaky.png"],
TestContext.Current.CancellationToken
);
seen["https://example.test/flaky.png"].ShouldBe(SeenOutcome.Failed);
SeenOutcomes.IsTerminal(SeenOutcome.Failed).ShouldBeFalse();
}
[Fact]
public async Task Purging_a_source_leaves_a_blob_that_another_source_still_references()
{
// This is exactly what the reference count buys; without it the second source would find
// its own index rows pointing at a file that the first source's purge deleted.
await StoreAsync("shared", sourceId: "url-list", url: "https://example.test/a.png");
await StoreAsync("shared", sourceId: "own-service", url: "https://own.test/a.png");
var result = await _store.PurgeAsync(new PurgeOptions("url-list"), TestContext.Current.CancellationToken);
result.ItemsRemoved.ShouldBe(1);
result.BlobsRemoved.ShouldBe(0);
var stats = await _store.GetStatsAsync(TestContext.Current.CancellationToken);
stats.BlobCount.ShouldBe(1);
stats.ItemCount.ShouldBe(1);
}
[Fact]
public async Task Purging_the_last_reference_deletes_the_file()
{
var item = await StoreAsync("lonely", sourceId: "url-list");
var path = _blobs.PathFor(item.Blob);
var result = await _store.PurgeAsync(new PurgeOptions("url-list"), TestContext.Current.CancellationToken);
result.BlobsRemoved.ShouldBe(1);
result.BytesFreed.ShouldBe(item.Blob.Length);
File.Exists(path).ShouldBeFalse();
}
[Fact]
public async Task Purging_keeps_the_journal_unless_asked_to_forget_it()
{
await StoreAsync("alpha", url: "https://example.test/a.png");
await _store.PurgeAsync(new PurgeOptions("url-list"), TestContext.Current.CancellationToken);
// Deliberate: otherwise the next run downloads again exactly what was just deleted.
var kept = await _store.GetSeenAsync(
"url-list",
["https://example.test/a.png"],
TestContext.Current.CancellationToken
);
kept.ShouldContainKey("https://example.test/a.png");
await _store.PurgeAsync(
new PurgeOptions("url-list") { ForgetSeenUrls = true },
TestContext.Current.CancellationToken
);
var forgotten = await _store.GetSeenAsync(
"url-list",
["https://example.test/a.png"],
TestContext.Current.CancellationToken
);
forgotten.ShouldBeEmpty();
}
[Fact]
public async Task Marking_a_placeholder_removes_every_copy_already_held()
{
var first = await StoreAsync("placeholder", url: "https://example.test/a.png");
await StoreAsync("placeholder", url: "https://example.test/b.png");
var path = _blobs.PathFor(first.Blob);
var removed = await _store.TombstoneAsync(
first.Blob.Sha256,
"dead link image",
TestContext.Current.CancellationToken
);
removed.ShouldBe(2);
File.Exists(path).ShouldBeFalse();
(await _store.LoadTombstonesAsync(TestContext.Current.CancellationToken)).ShouldContain(first.Blob.Sha256);
// And the addresses are remembered as placeholders, so a re-run does not fetch them again.
var seen = await _store.GetSeenAsync(
"url-list",
["https://example.test/a.png"],
TestContext.Current.CancellationToken
);
seen["https://example.test/a.png"].ShouldBe(SeenOutcome.Placeholder);
}
[Fact]
public async Task A_showcase_entry_is_the_same_content_and_deleting_it_leaves_the_blob()
{
var item = await StoreAsync("linked", suggestedName: "kitten");
item.ShowcasePath.ShouldNotBeNull();
var showcasePath = Path.Combine(
_paths.ShowcaseDirectory,
item.ShowcasePath!.Replace('/', Path.DirectorySeparatorChar)
);
File.ReadAllBytes(showcasePath).ShouldBe(File.ReadAllBytes(_blobs.PathFor(item.Blob)));
item.ShowcasePath.ShouldContain("kitten");
File.Delete(showcasePath);
File.Exists(_blobs.PathFor(item.Blob)).ShouldBeTrue();
}
[Fact]
public async Task A_filesystem_without_hard_links_falls_back_to_copying()
{
// Forced, because the developer's own disk supports hard links and this path would
// otherwise only ever run on a user's exFAT drive or network share.
var store = Build(
(string _, string _, out string? error) =>
{
error = "cross-volume";
return false;
}
);
var item = await StoreAsync("copied", store: store);
item.ShowcasePath.ShouldNotBeNull();
var showcasePath = Path.Combine(
_paths.ShowcaseDirectory,
item.ShowcasePath!.Replace('/', Path.DirectorySeparatorChar)
);
File.Exists(showcasePath).ShouldBeTrue();
File.ReadAllBytes(showcasePath).ShouldBe(File.ReadAllBytes(_blobs.PathFor(item.Blob)));
}
[Fact]
public async Task The_showcase_can_be_rebuilt_after_the_user_deletes_it()
{
await StoreAsync("one", url: "https://example.test/1.png", suggestedName: "one");
await StoreAsync("two", url: "https://example.test/2.png", suggestedName: "two");
Directory.Delete(_paths.ShowcaseDirectory, recursive: true);
var linked = await _store.RebuildShowcaseAsync("url-list", TestContext.Current.CancellationToken);
linked.ShouldBe(2);
Directory.EnumerateFiles(_paths.ShowcaseDirectory, "*", SearchOption.AllDirectories).Count().ShouldBe(2);
}
[Fact]
public async Task Reference_counts_do_not_drift()
{
await StoreAsync("a", url: "https://example.test/1.png");
await StoreAsync("a", url: "https://example.test/2.png");
await StoreAsync("b", url: "https://example.test/3.png");
await _store.PurgeAsync(new PurgeOptions("nobody"), TestContext.Current.CancellationToken);
(await _index.VerifyReferenceCountsAsync(TestContext.Current.CancellationToken)).ShouldBe(0);
}
[Fact]
public async Task A_run_records_what_it_did()
{
var runId = await _store.BeginRunAsync("url-list", TestContext.Current.CancellationToken);
runId.ShouldNotBeNullOrWhiteSpace();
await _store.CompleteRunAsync(
runId,
new RunSummary(10, 7, 2, 1, 4096, Cancelled: false),
TestContext.Current.CancellationToken
);
}
[Fact]
public async Task An_unfinished_download_from_a_previous_process_is_swept()
{
var stale = _blobs.CreateTempPath();
await File.WriteAllTextAsync(stale, "half a picture", TestContext.Current.CancellationToken);
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-1));
var fresh = _blobs.CreateTempPath();
await File.WriteAllTextAsync(fresh, "in progress right now", TestContext.Current.CancellationToken);
_blobs.SweepTemp(TimeSpan.FromHours(6)).ShouldBe(1);
File.Exists(stale).ShouldBeFalse();
File.Exists(fresh).ShouldBeTrue();
}
}