XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using Microsoft.Data.Sqlite;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using System.Security.Cryptography;
using XFEEDToolBox.Core.Services;

namespace XFEEDToolBox.Test;

[TestFixture]
[Isolated] // These fixtures clear process-wide SQLite pools and alter physical DB files.
public class MarketRecoveryTests
{
    private static void SealGeneration(MarketRepository repository)
    {
        SqliteConnection.ClearAllPools();MarketCacheHealth health;long latest;
        using(var connection=Open(repository.DatabasePath))
        {
            using var query=connection.CreateCommand();query.CommandText="PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();
            query.CommandText="PRAGMA integrity_check(1)";Check.Equal("ok",(string)query.ExecuteScalar()!);
            query.CommandText="SELECT (SELECT count(*) FROM stations),(SELECT count(*) FROM quotes),(SELECT count(*) FROM snapshots),(SELECT max(timestamp) FROM quotes),sqlite_version()";
            using var rows=query.ExecuteReader();rows.Read();health=new(rows.GetInt64(0),rows.GetInt64(1),rows.GetInt64(2),rows.GetString(4));latest=rows.GetInt64(3);
        }
        using var file=File.OpenRead(repository.DatabasePath);var hash=Convert.ToHexStringLower(SHA256.HashData(file));file.Dispose();
        repository.RecordVerifiedGeneration(health,latest,hash);
    }
    private static SqliteConnection Open(string path)
    {
        var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
        connection.Open(); return connection;
    }

    private static void DamageQuotePage(string path)
    {
        long root, size;
        SqliteConnection.ClearAllPools();
        using (var connection = Open(path))
        using (var command = connection.CreateCommand())
        {
            command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; command.ExecuteNonQuery();
            command.CommandText = "SELECT rootpage FROM sqlite_schema WHERE name='quotes'"; root = (long)command.ExecuteScalar()!;
            command.CommandText = "PRAGMA page_size"; size = (long)command.ExecuteScalar()!;
        }
        using var file = File.Open(path, FileMode.Open, FileAccess.Write, FileShare.None);
        file.Position = (root - 1) * size;
        file.Write(new byte[size]);
        file.Flush(true);
    }

    [Test] public async Task StartupRestoresRealCorruptedTableAndPreservesDamagedOriginal()
    {
        using var temp = new TemporaryDirectory(); var repository = temp.Repository();
        var at = DateTimeOffset.UtcNow.AddMinutes(-2);
        for (var i = 1; i <= 4; i++) await repository.SaveSnapshotAsync(new(Check.Station(i), [Check.Quote(i, "water", 12, 10), Check.Quote(i, "food", 20, 18)], at, DataSource.Eddn));
        await repository.SaveVerifiedBackupAsync();
        DamageQuotePage(repository.DatabasePath);
        Check.Throws<SqliteException>(() => repository.Search("", null));
        var restarted = new MarketRepository(repository.DatabasePath); restarted.Initialize();
        Check.That(restarted.LastRecovery is not null);
        Check.Equal(8L, restarted.Statistics().Quotes); Check.Equal(4L, restarted.Statistics().Stations);
        Check.Equal(8, restarted.Search("", null).Count);
        Check.That(File.Exists(restarted.LastRecovery!.OriginalBackup));
        using var damaged = Open(restarted.LastRecovery.OriginalBackup); using var check = damaged.CreateCommand();
        check.CommandText = "PRAGMA quick_check(1)"; Check.That((string?)check.ExecuteScalar() != "ok");
        await restarted.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "water", 13, 11)], at.AddMinutes(1), DataSource.Journal));
        SqliteConnection.ClearAllPools();
        var twice = new MarketRepository(repository.DatabasePath); twice.Initialize();
        Check.That(twice.LastRecovery is null); Check.Equal(7L, twice.Statistics().Quotes);
        Check.Equal(13L, twice.Search("water", 1).Single().Quote.BuyPrice);
    }

    [Test] public async Task FailedIncomingWriteRestoresThenRetriesTheSameSnapshot()
    {
        using var temp = new TemporaryDirectory(); var repository = temp.Repository(); var at = DateTimeOffset.UtcNow.AddMinutes(-2);
        await repository.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "water", 10, 9)], at, DataSource.Eddn));
        await repository.SaveVerifiedBackupAsync(); DamageQuotePage(repository.DatabasePath);
        Check.That(await repository.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "water", 12, 11)], at.AddMinutes(1), DataSource.Journal)));
        Check.Equal(12L, repository.Search("water", 1).Single().Quote.BuyPrice);
        Check.That(repository.LastRecovery is not null);
    }

    [Test] public async Task InvalidBackupCannotReplaceTheCorruptCacheWithAnEmptyDatabase()
    {
        using var temp = new TemporaryDirectory(); var repository = temp.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "water", 10, 9)], DateTimeOffset.UtcNow, DataSource.Journal));
        SqliteConnection.ClearAllPools();
        File.WriteAllBytes(repository.VerifiedBackupPath, new byte[4096]);
        DamageQuotePage(repository.DatabasePath);
        try { new MarketRepository(repository.DatabasePath).Initialize(); throw new Exception("Invalid recovery was accepted"); }
        catch (SqliteException) { }
        Check.Throws<SqliteException>(() => repository.Search("", null));
        Check.Equal(0, Directory.GetFiles(temp.Path, "*.corrupt-*.db").Length);
    }

    [Test] public async Task ConcurrentReadersAndTwoRepositoriesKeepWholeStationSnapshotsAcrossReopen()
    {
        using var temp = new TemporaryDirectory(); var first = temp.Repository(); var second = new MarketRepository(first.DatabasePath);
        var at = DateTimeOffset.UtcNow.AddMinutes(-5);
        var readers = Enumerable.Range(0, 3).Select(_ => Task.Run(() => { for (var n = 0; n < 40; n++) { first.Statistics(); first.Search("", null); first.GetStations(); } })).ToArray();
        var writers = Enumerable.Range(0, 2).Select(writer => Task.Run(async () =>
        {
            var repository = writer == 0 ? first : second;
            for (var n = 0; n < 40; n++) await repository.SaveSnapshotAsync(new(Check.Station(writer + 1), Enumerable.Range(1, 12).Select(i => Check.Quote(writer + 1, "commodity" + i, n + 1, 5)).ToArray(), at.AddSeconds(n), DataSource.Eddn));
        })).ToArray();
        await Task.WhenAll(readers.Concat(writers)); await first.SaveVerifiedBackupAsync(); SqliteConnection.ClearAllPools();
        var restarted = new MarketRepository(first.DatabasePath); restarted.Initialize();
        Check.Equal(24L, restarted.Statistics().Quotes); Check.Equal(2L, restarted.Statistics().Stations);
        foreach (var sort in Enum.GetValues<MarketSort>()) Check.Equal(24, restarted.Search("", null, sort: sort).Count);
        Check.That(restarted.Search("", null).All(x => x.Quote.BuyPrice == 40));
    }

    [Test] public async Task EmptyMarketCannotOverwriteAUsefulVerifiedBackup()
    {
        using var temp=new TemporaryDirectory();var repository=temp.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-2);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Eddn));
        await repository.SaveVerifiedBackupAsync();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[],at.AddMinutes(1),DataSource.Journal));
        try{await repository.SaveVerifiedBackupAsync();throw new Exception("Empty backup was accepted");}catch(InvalidDataException){}
        using var backup=Open(repository.VerifiedBackupPath);using var query=backup.CreateCommand();
        query.CommandText="SELECT count(*) FROM quotes";Check.Equal(1L,(long)query.ExecuteScalar()!);
    }
    [Test]public async Task TruncatedDatabaseRestoresThroughANewGenerationWithoutChangingOriginalBytes()
    {
        using var temporary=new TemporaryDirectory();var original=temporary.File("markets.db");var repository=new MarketRepository(original);repository.Initialize();
        var at=DateTimeOffset.UtcNow.AddMinutes(-2);
        await repository.SaveSnapshotAsync(new(Check.Station(1),Enumerable.Range(0,100).Select(index=>Check.Quote(1,"commodity"+index,10,9)).ToArray(),at,DataSource.Eddn));
        await repository.SaveVerifiedBackupAsync();SqliteConnection.ClearAllPools();
        using(var file=new FileStream(original,FileMode.Open,FileAccess.Write,FileShare.None)){file.SetLength(4096);file.Flush(true);}
        var damaged=File.ReadAllBytes(original);
        var restarted=new MarketRepository(original);restarted.Initialize();
        Check.That(restarted.LastRecovery is{OriginalPreservedInPlace:true});Check.That(restarted.DatabasePath!=original);
        Check.Equal(original,restarted.LastRecovery!.OriginalBackup);Check.That(File.ReadAllBytes(original).SequenceEqual(damaged));
        Check.Equal(100L,restarted.Statistics().Quotes);Check.Equal(100L,repository.Statistics().Quotes);
        Check.That(File.Exists(original+".active"));Check.Equal(0,Directory.EnumerateFiles(temporary.Path,"*.corrupt-*.db").Count());
        SqliteConnection.ClearAllPools();var reopened=new MarketRepository(original);reopened.Initialize();
        Check.That(reopened.LastRecovery is null);Check.Equal(restarted.DatabasePath,reopened.DatabasePath);Check.Equal(100L,reopened.Statistics().Quotes);
        await reopened.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",15,14)],at.AddMinutes(1),DataSource.Journal));
        Check.Equal(1L,repository.Statistics().Quotes);Check.That(File.ReadAllBytes(original).SequenceEqual(damaged));
    }
    [Test]public void ActiveMarkerCannotRedirectTheCacheOutsideItsOwnedGeneration()
    {
        using var temporary=new TemporaryDirectory();var original=temporary.File("markets.db");
        File.WriteAllText(original+".active","../another.db");
        Check.Throws<InvalidDataException>(()=>_ = new MarketRepository(original).DatabasePath);
        File.WriteAllText(original+".active","markets.db.recovered-missing.db");
        Check.Throws<InvalidDataException>(()=>_ = new MarketRepository(original).DatabasePath);
    }
    [Test]public async Task InsufficientBackupSpacePreservesTheExistingVerifiedFile()
    {
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));
        await repository.SaveVerifiedBackupAsync();SqliteConnection.ClearAllPools();var before=File.ReadAllBytes(repository.VerifiedBackupPath);
        var maintenance=typeof(MarketRepository).Assembly.GetType("XFEEDToolBox.Core.Data.MarketCacheMaintenance")!;
        var backup=maintenance.GetMethod("SaveVerifiedBackup",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic)!;
        var sourceBytes=new FileInfo(repository.DatabasePath).Length+(File.Exists(repository.DatabasePath+"-wal")?new FileInfo(repository.DatabasePath+"-wal").Length:0);
        foreach(var available in new[]{0L,sourceBytes+256L*1024*1024+1})
        {
            try{backup.Invoke(null,[repository.DatabasePath,repository.VerifiedBackupPath,(Func<long>)(()=>available)]);throw new Exception("Insufficient backup space was accepted");}
            catch(System.Reflection.TargetInvocationException error)when(error.InnerException is IOException){}
        }
        Check.That(File.ReadAllBytes(repository.VerifiedBackupPath).SequenceEqual(before));Check.Equal(1L,repository.Statistics().Quotes);
    }
    [Test]public async Task SealedGenerationStartsWithoutScanningOrReplacingTheOlderBackup()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9),Check.Quote(1,"food",15,14)],DateTimeOffset.UtcNow,DataSource.Journal));
        SealGeneration(repository);File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);
        var restarted=new MarketRepository(repository.DatabasePath);restarted.Initialize();
        Check.Equal(2L,restarted.Statistics().Quotes);Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);
        Check.That(File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl").Contains("startup-verified-generation"));
    }
    [Test]public async Task SealedGenerationRejectsPageDamageEvenWhenTheOriginalModifiedTimeIsRestored()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));
        await repository.SaveVerifiedBackupAsync();SealGeneration(repository);var modified=File.GetLastWriteTimeUtc(repository.DatabasePath);
        DamageQuotePage(repository.DatabasePath);File.SetLastWriteTimeUtc(repository.DatabasePath,modified);
        var restarted=new MarketRepository(repository.DatabasePath);restarted.Initialize();
        Check.That(restarted.LastRecovery is not null);Check.Equal(1L,restarted.Statistics().Quotes);
    }
    [Test]public async Task AReplacedPhysicalDatabaseCannotReuseAnExistingGenerationReceipt()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));
        SealGeneration(repository);var modified=File.GetLastWriteTimeUtc(repository.DatabasePath);var creation=File.GetCreationTimeUtc(repository.DatabasePath);
        var parked=repository.DatabasePath+".parked";SqliteConnection.ClearAllPools();File.Move(repository.DatabasePath,parked);File.Copy(parked,repository.DatabasePath);
        File.SetCreationTimeUtc(repository.DatabasePath,creation);File.SetLastWriteTimeUtc(repository.DatabasePath,modified);
        File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);
        var restarted=new MarketRepository(repository.DatabasePath);restarted.Initialize();
        Check.Equal(1L,restarted.Statistics().Quotes);Check.That(new FileInfo(repository.VerifiedBackupPath).Length>4096);
    }
    [Test]public async Task CachedStatisticsTrackReplacementEmptySnapshotsAndForeignWriters()
    {
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-3);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9),Check.Quote(1,"food",15,14)],at,DataSource.Journal));
        Check.Equal(2L,repository.Statistics().Quotes);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",11,10)],at.AddMinutes(1),DataSource.Journal));
        Check.Equal(1L,repository.Statistics().Quotes);
        var other=new MarketRepository(repository.DatabasePath);await other.SaveSnapshotAsync(new(Check.Station(2),[Check.Quote(2,"food",15,14)],at.AddMinutes(1),DataSource.Journal));
        Check.Equal(2L,repository.Statistics().Quotes);Check.Equal(2L,repository.Statistics().Stations);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[],at.AddMinutes(2),DataSource.Journal));
        Check.Equal(1L,repository.Statistics().Quotes);Check.Equal(at.AddMinutes(1).ToUnixTimeMilliseconds(),repository.Statistics().Latest!.Value.ToUnixTimeMilliseconds());
    }
    [Test]public async Task TrustedWritesAndNormalCloseKeepTheNextStartupFast()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-2);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Journal));SealGeneration(repository);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",12,11),Check.Quote(1,"food",20,19)],at.AddMinutes(1),DataSource.Journal));
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));SqliteConnection.ClearAllPools();
        File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);
        var restarted=new MarketRepository(repository.DatabasePath);restarted.Initialize();
        Check.Equal(2L,restarted.Statistics().Quotes);Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);
        Check.That(File.ReadAllText(repository.DatabasePath+".verified-generation.json").Contains("\"TrustedWriteContinuation\": true"));
    }
    [Test]public async Task UnknownExternalWritesCannotBeSealedAsTrustedContinuation()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        using(var connection=Open(repository.DatabasePath)){using var query=connection.CreateCommand();query.CommandText="UPDATE quotes SET buy=999 WHERE station=1";query.ExecuteNonQuery();}
        Check.That(!await repository.FlushVerifiedGenerationAsync());
        Check.That(!File.ReadAllText(repository.DatabasePath+".verified-generation.json").Contains("\"TrustedWriteContinuation\": true"));
    }
    [Test]public async Task AWriteBetweenCheckpointAndReceiptPublicationCannotGainAContinuation()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        var proofPath=repository.DatabasePath+".verified-generation.json";var before=File.ReadAllBytes(proofPath);
        using var document=System.Text.Json.JsonDocument.Parse(before);var root=document.RootElement;
        var health=System.Text.Json.JsonSerializer.Deserialize<MarketCacheHealth>(root.GetProperty("Health"))!;
        var latest=root.GetProperty("Latest").GetInt64();var hash=root.GetProperty("Sha256").GetString()!;
        var type=typeof(MarketRepository).Assembly.GetType("XFEEDToolBox.Core.Data.MarketVerifiedGeneration")!;
        var capture=type.GetMethod("TryCapture",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic)!;
        object?[] arguments=[repository.DatabasePath,null];Check.That((bool)capture.Invoke(null,arguments)!);var expected=arguments[1];
        using(var connection=Open(repository.DatabasePath))
        {using var query=connection.CreateCommand();query.CommandText="UPDATE quotes SET buy=999 WHERE station=1; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var record=type.GetMethod("Record",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic)!;
        try{record.Invoke(null,[repository.DatabasePath,health,latest,hash,true,expected]);throw new Exception("Changed checkpoint received a trusted continuation");}
        catch(System.Reflection.TargetInvocationException error)when(error.InnerException is IOException){}
        Check.That(File.ReadAllBytes(proofPath).SequenceEqual(before));Check.That(!await repository.FlushVerifiedGenerationAsync());
    }
    [Test]public async Task AnOpenForeignWritableHandlePreventsReceiptPublication()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        var proofPath=repository.DatabasePath+".verified-generation.json";var before=File.ReadAllBytes(proofPath);
        using var document=System.Text.Json.JsonDocument.Parse(before);var root=document.RootElement;
        var health=System.Text.Json.JsonSerializer.Deserialize<MarketCacheHealth>(root.GetProperty("Health"))!;
        using(var handle=File.OpenHandle(repository.DatabasePath,FileMode.Open,FileAccess.ReadWrite,FileShare.ReadWrite|FileShare.Delete))
            Check.Throws<IOException>(()=>repository.RecordVerifiedGeneration(health,root.GetProperty("Latest").GetInt64(),root.GetProperty("Sha256").GetString()!));
        Check.That(File.ReadAllBytes(proofPath).SequenceEqual(before));
    }
    [Test]public async Task BoundedStablePagesPreserveGlobalOrderIncludingDuplicateStationNames()
    {
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-1);
        var definitions=new[]{(1L,"0 Empty","First",Array.Empty<string>()),(2L,"Alpha","Duplicate",new[]{"zinc","gold"}),(3L,"Alpha","Duplicate",new[]{"apple","titanium"}),(4L,"Alpha","Later",new[]{"silver"}),(5L,"alpha","Later",new[]{"uranium"}),(6L,"Beta","Last",new[]{"food","water"}),(7L,"Delta","Empty",Array.Empty<string>())};
        foreach(var(id,system,name,goods)in definitions)
            await repository.SaveSnapshotAsync(new(Check.Station(id) with{SystemName=system,Name=name},goods.Select((key,index)=>Check.Quote(id,key,20-index,10)).ToArray(),at,DataSource.Journal));
        var expected=new List<string>();using(var connection=Open(repository.DatabasePath))
        {using var query=connection.CreateCommand();query.CommandText="SELECT s.id,q.commodity FROM quotes q JOIN stations s ON s.id=q.station ORDER BY s.system,s.name,q.commodity";using var reader=query.ExecuteReader();while(reader.Read())expected.Add(reader.GetInt64(0)+":"+reader.GetString(1));}
        foreach(var limit in new[]{1,2,3,150})foreach(var offset in Enumerable.Range(0,expected.Count+2))
        {var actual=repository.Search("",null,offset,limit).Select(row=>row.Station.MarketId+":"+row.Quote.Commodity);Check.Equal(string.Join('|',expected.Skip(offset).Take(limit)),string.Join('|',actual));}
        Check.Equal("zinc",repository.Search("",2,sort:MarketSort.BuyDescending).First().Quote.Commodity);
        Check.Equal(1,repository.Search("app",3).Count);Check.Equal(0,repository.Search("app",2).Count);
    }
    [Test]public async Task AHealthyChangedGenerationGetsANewShaReceiptAndItsNextLaunchIsFast()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        var proof=repository.DatabasePath+".verified-generation.json";var old=File.ReadAllBytes(proof);
        using(var changed=Open(repository.DatabasePath)){using var query=changed.CreateCommand();query.CommandText="UPDATE quotes SET buy=99; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var reopened=new MarketRepository(repository.DatabasePath);reopened.Initialize();SqliteConnection.ClearAllPools();
        using(var receipt=System.Text.Json.JsonDocument.Parse(File.ReadAllText(proof)))
        {
            Check.That(!receipt.RootElement.GetProperty("TrustedWriteContinuation").GetBoolean());
            Check.Equal(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(repository.DatabasePath))),receipt.RootElement.GetProperty("Sha256").GetString()!);
        }
        Check.That(!File.ReadAllBytes(proof).SequenceEqual(old));
        File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);
        var twice=new MarketRepository(repository.DatabasePath);twice.Initialize();
        Check.Equal(1L,twice.Statistics().Quotes);Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);
        Check.That(File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl").Contains("verified-generation-rebuilt"));
    }
    [Test]public async Task FullValidationCanSealAfterABlockingHandleClosesWithoutAnotherFullScan()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        using(var changed=Open(repository.DatabasePath)){using var query=changed.CreateCommand();query.CommandText="UPDATE quotes SET buy=98; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var proof=repository.DatabasePath+".verified-generation.json";var old=File.ReadAllBytes(proof);var reopened=new MarketRepository(repository.DatabasePath);
        using(var blocker=File.OpenHandle(repository.DatabasePath,FileMode.Open,FileAccess.ReadWrite,FileShare.ReadWrite|FileShare.Delete))
        {reopened.Initialize();Check.That(File.ReadAllBytes(proof).SequenceEqual(old));Check.That(!await reopened.FlushVerifiedGenerationAsync());}
        Check.That(await reopened.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
        using var receipt=System.Text.Json.JsonDocument.Parse(File.ReadAllText(proof));
        Check.That(!receipt.RootElement.GetProperty("TrustedWriteContinuation").GetBoolean());
        Check.Equal(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(repository.DatabasePath))),receipt.RootElement.GetProperty("Sha256").GetString()!);
    }
    [Test]public async Task AForeignWriteAfterFullValidationCannotAcquireThePendingReceipt()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        using(var changed=Open(repository.DatabasePath)){using var query=changed.CreateCommand();query.CommandText="UPDATE quotes SET buy=97; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var proof=repository.DatabasePath+".verified-generation.json";var old=File.ReadAllBytes(proof);var reopened=new MarketRepository(repository.DatabasePath);
        using(var blocker=File.OpenHandle(repository.DatabasePath,FileMode.Open,FileAccess.ReadWrite,FileShare.ReadWrite|FileShare.Delete))
        {
            reopened.Initialize();
            using var foreign=Open(repository.DatabasePath);using var query=foreign.CreateCommand();query.CommandText="UPDATE quotes SET buy=96; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();
        }
        Check.That(!await reopened.FlushVerifiedGenerationAsync());Check.That(File.ReadAllBytes(proof).SequenceEqual(old));
    }
    [Test]public async Task RuntimeCloseRetriesTheActualTrustedWriteChainAfterAReaderReleasesIt()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var runtime=new ToolboxRuntime(temporary.Path);await runtime.InitializeAsync(offline:true,validateMarketCache:true);
        var at=DateTimeOffset.UtcNow.AddMinutes(-2);await runtime.Markets.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Journal));SealGeneration(runtime.Markets);
        var blocker=File.OpenHandle(runtime.Markets.DatabasePath,FileMode.Open,FileAccess.ReadWrite,FileShare.ReadWrite|FileShare.Delete);
        try
        {
            await runtime.Markets.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",12,11),Check.Quote(1,"food",20,19)],at.AddMinutes(1),DataSource.Journal));
            var release=Task.Run(async()=>{await Task.Delay(120);blocker.Dispose();});
            await runtime.DisposeAsync();await release;
        }
        finally{blocker.Dispose();}
        using var receipt=System.Text.Json.JsonDocument.Parse(File.ReadAllText(runtime.Markets.DatabasePath+".verified-generation.json"));
        Check.That(receipt.RootElement.GetProperty("TrustedWriteContinuation").GetBoolean());Check.Equal(2L,receipt.RootElement.GetProperty("Health").GetProperty("Quotes").GetInt64());
        File.WriteAllBytes(runtime.Markets.VerifiedBackupPath,new byte[4096]);var reopened=new MarketRepository(runtime.Markets.DatabasePath);reopened.Initialize();
        Check.Equal(2L,reopened.Statistics().Quotes);Check.Equal(4096L,new FileInfo(runtime.Markets.VerifiedBackupPath).Length);
    }
    [Test]public async Task RuntimeExitDoesNotStartTheDeferredFullFileHash()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        using(var changed=Open(repository.DatabasePath)){using var query=changed.CreateCommand();query.CommandText="UPDATE quotes SET buy=95; PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var proof=repository.DatabasePath+".verified-generation.json";var old=File.ReadAllBytes(proof);var runtime=new ToolboxRuntime(temporary.Path);
        using(var blocker=File.OpenHandle(repository.DatabasePath,FileMode.Open,FileAccess.ReadWrite,FileShare.ReadWrite|FileShare.Delete))
            await runtime.InitializeAsync(offline:true,validateMarketCache:true);
        // The blocker has gone. A normal explicit flush can now hash and seal,
        // but closing the application must only retry an existing trusted chain.
        await runtime.DisposeAsync();Check.That(File.ReadAllBytes(proof).SequenceEqual(old));
        Check.That(File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl").Contains("verified-generation-close-unsealed"));
        Check.That(await runtime.Markets.FlushVerifiedGenerationAsync(),"The deferred full validation should remain available for an explicit flush.");
        Check.That(!File.ReadAllBytes(proof).SequenceEqual(old));
    }
    [Test]public async Task LargerIntegrityPagerCacheIsLocalReadOnlyAndStillRejectsDamagedPages()
    {
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),Enumerable.Range(0,100).Select(index=>Check.Quote(1,"commodity"+index,10,9)).ToArray(),DateTimeOffset.UtcNow,DataSource.Journal));
        SqliteConnection.ClearAllPools();using(var checkpoint=Open(repository.DatabasePath)){using var query=checkpoint.CreateCommand();query.CommandText="PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();}
        var maintenance=typeof(MarketRepository).Assembly.GetType("XFEEDToolBox.Core.Data.MarketCacheMaintenance")!;
        var fullCheck=maintenance.GetMethods(System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic)
            .Single(method=>method.Name=="Check"&&method.GetParameters()[0].ParameterType==typeof(SqliteConnection));
        foreach(var corrupt in new[]{false,true})
        {
            if(corrupt)DamageQuotePage(repository.DatabasePath);
            var before=Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(repository.DatabasePath)));
            using(var readOnly=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=repository.DatabasePath,Mode=SqliteOpenMode.ReadOnly,Pooling=false}.ToString()))
            {
                readOnly.Open();using var cache=readOnly.CreateCommand();cache.CommandText="PRAGMA cache_size=-8192";cache.ExecuteNonQuery();
                var rejected=false;
                try{var health=(MarketCacheHealth)fullCheck.Invoke(null,[readOnly,true,null])!;Check.Equal(100L,health.Quotes);}
                catch(System.Reflection.TargetInvocationException error)when(error.InnerException is SqliteException or InvalidDataException){rejected=true;}
                Check.Equal(corrupt,rejected);cache.CommandText="PRAGMA cache_size";Check.Equal(-8192L,(long)cache.ExecuteScalar()!);
            }
            Check.Equal(before,Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(repository.DatabasePath))));
        }
        Check.That(File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl").Contains("\"appliedCacheKiB\":-131072"));
    }
    [Test]public async Task RuntimeCloseDuringBlockedMarketInitializationDoesNotWaitOrStartSourcesLater()
    {
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();SqliteConnection.ClearAllPools();
        var settings=new ToolboxSettings{JournalDirectory=temporary.Path,ConnectEddnOnStart=false};settings.Save(temporary.File("settings.json"));
        var runtime=new ToolboxRuntime(temporary.Path);using var blocker=Open(repository.DatabasePath);using var command=blocker.CreateCommand();
        command.CommandText="PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE";command.ExecuteNonQuery();
        var initialization=runtime.InitializeAsync(validateMarketCache:true);
        try
        {
            var gate=(SemaphoreSlim)typeof(MarketRepository).GetField("gate",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic)!.GetValue(runtime.Markets)!;
            var until=DateTimeOffset.UtcNow.AddSeconds(2);while(gate.CurrentCount!=0&&DateTimeOffset.UtcNow<until)await Task.Delay(10);
            Check.Equal(0,gate.CurrentCount);Check.That(!initialization.IsCompleted,"The actual SQLite lock must hold market initialization in flight.");
            await runtime.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
            Check.That(!initialization.IsCompleted,"Closing must not wait for the blocked market initializer.");
        }
        finally{command.CommandText="ROLLBACK";command.ExecuteNonQuery();}
        await initialization.WaitAsync(TimeSpan.FromSeconds(3));
        Check.That(!File.Exists(temporary.File("game-events.db")),"The late initializer must not create or start the event/writer source chain after disposal.");
        Check.That(runtime.Eddn is null);Check.Equal(0L,runtime.Journal.EventsRead);
        Check.That(!runtime.Activity.Any(entry=>entry.Kind=="系统"&&entry.Message.Contains("Journal 监听已启动")));
        Check.That(File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl").Contains("Market initialization has not completed"));
    }
    [Test]public async Task ActualSearchIdlePoolDoesNotBlockTheNextTrustedStationWriteSeal()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-1);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Journal));SealGeneration(repository);
        Check.Equal(1,repository.Search("",1).Count);
        await repository.SaveStationAsync(Check.Station(1) with{Name="Updated station",UpdatedAt=at.AddSeconds(1)});
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
        using(var receipt=System.Text.Json.JsonDocument.Parse(File.ReadAllText(repository.DatabasePath+".verified-generation.json")))
            Check.That(receipt.RootElement.GetProperty("TrustedWriteContinuation").GetBoolean());
        File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);var restarted=new MarketRepository(repository.DatabasePath);restarted.Initialize();
        Check.Equal(1L,restarted.Statistics().Quotes);Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);
    }
    [Test]public async Task ActiveSearchPoolReaderStaysUsableAndDefersSealingUntilReleased()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-1);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9),Check.Quote(1,"food",20,19)],at,DataSource.Journal));SealGeneration(repository);
        Check.Equal(2,repository.Search("",1).Count);var proof=repository.DatabasePath+".verified-generation.json";var before=File.ReadAllBytes(proof);
        var readOnlyString=new SqliteConnectionStringBuilder{DataSource=repository.DatabasePath,Mode=SqliteOpenMode.ReadOnly,Pooling=false,DefaultTimeout=20}.ToString();
        using(var active=new SqliteConnection(readOnlyString))
        {
            active.Open();
            // Hold a real pre-write snapshot, rather than merely opening a
            // connection that has not yet acquired a WAL read mark.
            using var query=active.CreateCommand();query.CommandText="SELECT commodity FROM quotes ORDER BY commodity";using var reader=query.ExecuteReader();Check.That(reader.Read());
            await repository.SaveStationAsync(Check.Station(1) with{Name="Active reader fixture",UpdatedAt=at.AddSeconds(1)});
            Check.That(!await repository.FlushVerifiedGenerationAsync());Check.That(File.ReadAllBytes(proof).SequenceEqual(before));
            Check.That(reader.Read(),"The active reader must remain usable after its pool's idle connections are cleared.");
        }
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
    }
    [Test]public async Task ClearingAnIdleSearchPoolNeverTrustsAnInterveningForeignSqlWrite()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SealGeneration(repository);
        Check.Equal(1,repository.Search("",1).Count);var proof=repository.DatabasePath+".verified-generation.json";var before=File.ReadAllBytes(proof);
        using(var foreign=Open(repository.DatabasePath)){using var write=foreign.CreateCommand();write.CommandText="UPDATE quotes SET buy=777";write.ExecuteNonQuery();}
        Check.That(!await repository.FlushVerifiedGenerationAsync());Check.That(File.ReadAllBytes(proof).SequenceEqual(before));
    }
    [Test]public async Task PublicStatisticsReadersCannotEraseTheConcurrentTrustedWriteChain()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-5);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Journal));SealGeneration(repository);
        using var done=new CancellationTokenSource();var observed=0;
        var readers=Enumerable.Range(0,3).Select(_=>Task.Run(()=>
        {
            while(!done.IsCancellationRequested)
            {var statistics=repository.Statistics();Check.That(statistics.Stations>=1&&statistics.Quotes>=1);Interlocked.Increment(ref observed);}
        })).ToArray();
        try
        {
            for(var index=0;index<80;index++)
            {
                await repository.SaveStationAsync(Check.Station(1) with{Name="Concurrent "+index,UpdatedAt=at.AddSeconds(index+1)});
                await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",index+11,9)],at.AddSeconds(index+1),DataSource.Journal));
            }
        }
        finally{done.Cancel();await Task.WhenAll(readers);}
        Check.That(observed>0);Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
        File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]);var reopened=new MarketRepository(repository.DatabasePath);reopened.Initialize();
        Check.Equal(1L,reopened.Statistics().Quotes);Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);
    }
    [Test]public async Task AnExtendedUnicodeProfilePathKeepsTheSamePhysicalTrustedGeneration()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var folder=Path.Combine(temporary.Path,"用户 profile with spaces",string.Join('-',Enumerable.Repeat("long-profile-segment",5)));
        Directory.CreateDirectory(folder);var path=Path.Combine(folder,"markets.db");var extended="\\\\?\\"+Path.GetFullPath(path);
        var seed=new MarketRepository(path);seed.Initialize();var at=DateTimeOffset.UtcNow.AddMinutes(-1);
        await seed.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Journal));SealGeneration(seed);
        var repository=new MarketRepository(extended);repository.Initialize();
        Check.Equal(1,repository.Search("",1).Count);repository.Statistics();
        await repository.SaveStationAsync(Check.Station(1) with{Name="Unicode profile station",UpdatedAt=at.AddSeconds(1)});
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
        Check.Equal(1L,new MarketRepository(path).Statistics().Quotes);
    }
    [Test]public async Task AReturnedWritableQueryHandleCannotLeaveAStampCapturedBeforeItsClose()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-2);
        await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],at,DataSource.Eddn));SealGeneration(repository);
        Check.Equal(1,repository.Search("",1).Count);
        var pooled=new SqliteConnectionStringBuilder{DataSource=repository.DatabasePath,Mode=SqliteOpenMode.ReadWriteCreate,DefaultTimeout=20}.ToString();
        using(var readerConnection=new SqliteConnection(pooled))
        {
            readerConnection.Open();using(var query=readerConnection.CreateCommand()){query.CommandText="SELECT name FROM stations";query.ExecuteScalar();}
            await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",11,9)],at.AddSeconds(1),DataSource.Eddn));
        }
        // Closing a borrowed pool connection physically closes it after the
        // writer's ClearPool, publishing the DB's delayed NTFS write time.
        Check.Equal(1L,repository.Statistics().Quotes);
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
    }
    [Test]public async Task EddnWritesWithRealPlanningQueriesKeepTheAnchorAfterAllQueryHandlesClose()
    {
        if(!OperatingSystem.IsWindows())return;
        using var temporary=new TemporaryDirectory();var repository=temporary.Repository();var at=DateTimeOffset.UtcNow.AddMinutes(-2);
        for(var id=1;id<=20;id++)await repository.SaveSnapshotAsync(new(Check.Station(id),Enumerable.Range(0,1000).Select(n=>Check.Quote(id,"commodity"+n,10,9)).ToArray(),at,DataSource.Eddn));
        SealGeneration(repository);using var done=new CancellationTokenSource();var observed=0;
        var readers=Enumerable.Range(0,3).Select(index=>Task.Run(()=>
        {
            while(!done.IsCancellationRequested)
            {
                if(index==0){repository.GetStation(1);repository.GetStations();}
                else if(index==1)repository.LoadRegion(1,1000);
                else repository.Search("",1);
                Interlocked.Increment(ref observed);
            }
        })).ToArray();
        try
        {
            for(var n=0;n<80;n++)
            {
                await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",n+11,9)],at.AddSeconds(n+1),DataSource.Eddn));
                repository.Statistics();
            }
        }
        finally{done.Cancel();await Task.WhenAll(readers);}
        Check.That(observed>0);repository.Statistics();
        Check.That(await repository.FlushVerifiedGenerationAsync(),File.ReadAllText(repository.DatabasePath+".diagnostics.jsonl"));
    }
}