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.Instance); _blobs = new BlobStore(_paths, NullLogger.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.Instance, hardLink), NullLogger.Instance ); /// Writes bytes to a staged temp file and returns the blob describing them. 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 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(); } }