using System.Reflection; using Microsoft.Data.Sqlite; using XFEExtension.NetCore.XUnit.Attributes; using XFEEDToolBox.Core.Data; using XFEEDToolBox.Core.Models; namespace XFEEDToolBox.Test; [TestFixture] public class MarketCacheCleanupTests { private static MarketRepository Cheap(TemporaryDirectory directory) {var repository=new MarketRepository(directory.File("markets.db"));repository.InitializeCheap();return repository;} private static SqliteConnection Open(string path) {var connection=new SqliteConnection(new SqliteConnectionStringBuilder{DataSource=path,Pooling=false}.ToString());connection.Open();return connection;} [Test]public async Task CheapStartupAndFirstObservationDoNotValidateBackupOrCreateReceipts() { using var directory=new TemporaryDirectory();var repository=Cheap(directory); await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal)); Check.That(!File.Exists(repository.VerifiedBackupPath));Check.That(!File.Exists(repository.DatabasePath+".verified-generation.json")); Check.That(!await repository.FlushVerifiedGenerationAsync());Check.Equal(1L,repository.Statistics().Quotes); SqliteConnection.ClearAllPools();File.WriteAllBytes(repository.VerifiedBackupPath,new byte[4096]); var reopened=new MarketRepository(repository.DatabasePath);reopened.InitializeCheap(); Check.Equal(4096L,new FileInfo(repository.VerifiedBackupPath).Length);Check.Equal(1,reopened.Search("",1).Count); } [Test]public async Task CheapModeRefusesCorruptReadsAndWritesWithoutAutomaticRestore() { using var directory=new TemporaryDirectory();var repository=Cheap(directory); await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SqliteConnection.ClearAllPools(); long root,size;using(var connection=Open(repository.DatabasePath)) {using var query=connection.CreateCommand();query.CommandText="PRAGMA wal_checkpoint(TRUNCATE)";query.ExecuteNonQuery();query.CommandText="SELECT rootpage FROM sqlite_schema WHERE name='quotes'";root=(long)query.ExecuteScalar()!;query.CommandText="PRAGMA page_size";size=(long)query.ExecuteScalar()!;} using(var file=File.Open(repository.DatabasePath,FileMode.Open,FileAccess.Write,FileShare.None)){file.Position=(root-1)*size;file.Write(new byte[size]);file.Flush(true);} var reopened=new MarketRepository(repository.DatabasePath);reopened.InitializeCheap(); Check.Throws(()=>reopened.Search("",1)); try{await reopened.RecoverAsync();throw new Exception("Cheap recovery performed full validation");}catch(InvalidDataException){} try{await reopened.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",12,10)],DateTimeOffset.UtcNow,DataSource.Journal));throw new Exception("Corrupt write unexpectedly succeeded");}catch(SqliteException){} Check.That(!File.Exists(directory.File("markets.db.active")));Check.That(!Directory.EnumerateFiles(directory.Path,"markets.db.recovered-*").Any()); } [Test]public async Task ExplicitClearRemovesOnlyOwnedMarketFamilyAndRebuildsAnEmptySmallCache() { using var directory=new TemporaryDirectory();var repository=Cheap(directory); await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SqliteConnection.ClearAllPools(); var original=repository.DatabasePath;var generation=directory.File("markets.db.recovered-download-fixture.db");File.Copy(original,generation); File.WriteAllText(original+".active",Path.GetFileName(generation)); foreach(var suffix in new[]{"-wal","-shm",".verified-generation.json",".diagnostics.jsonl"})File.WriteAllText(generation+suffix,"old-cache-evidence"); File.WriteAllText(original+".verified.db","old verified");File.WriteAllText(directory.File("markets.db.corrupt-fixture.db"),"old corrupt"); File.WriteAllText(original+".verified.db."+Guid.NewGuid().ToString("N")+".tmp","unfinished backup"); var personal=new[]{"settings.json","game-events.db","game-events.db.active","Journal.2026.log","trade-route.json","other-markets.db"}; foreach(var name in personal)File.WriteAllText(directory.File(name),"personal:"+name); var before=repository.GetCacheUsage();Check.That(before.HasLegacyFullCache);Check.That(before.Files>=9); var cleared=await repository.ClearAsync();Check.That(cleared.Succeeded,cleared.Message);Check.That(cleared.FreedBytes>0); foreach(var name in personal)Check.Equal("personal:"+name,File.ReadAllText(directory.File(name))); Check.That(!File.Exists(generation));Check.That(!File.Exists(original+".active"));Check.That(!File.Exists(original+".verified.db")); Check.That(cleared.Usage.Bytes<1024*1024);Check.That(!cleared.Usage.HasLegacyFullCache);Check.Equal(0L,repository.Statistics().Quotes); await repository.SaveSnapshotAsync(new(Check.Station(2),[Check.Quote(2,"food",20,19)],DateTimeOffset.UtcNow,DataSource.Journal)); Check.Equal(1,repository.Search("",2).Count);Check.That(!File.Exists(repository.VerifiedBackupPath)); } [Test]public async Task AnOccupiedFamilyMemberRefusesClearBeforeAnyOriginalIsMoved() { using var directory=new TemporaryDirectory();var repository=Cheap(directory); await repository.SaveSnapshotAsync(new(Check.Station(1),[Check.Quote(1,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal));SqliteConnection.ClearAllPools(); var before=File.ReadAllBytes(repository.DatabasePath);File.WriteAllText(repository.VerifiedBackupPath,"do not move this occupied backup"); using(var foreign=File.OpenHandle(repository.VerifiedBackupPath,FileMode.Open,FileAccess.Read,FileShare.Read)) {var result=await repository.ClearAsync();Check.That(!result.Succeeded);} Check.That(File.ReadAllBytes(repository.DatabasePath).SequenceEqual(before));Check.Equal("do not move this occupied backup",File.ReadAllText(repository.VerifiedBackupPath)); Check.That(!Directory.EnumerateDirectories(directory.Path,"markets.db.cleanup-*").Any());Check.Equal(1,repository.Search("",1).Count); } [Test]public async Task InvalidActivePointerCanBeClearedWithoutFollowingItOutsideTheFamily() { using var directory=new TemporaryDirectory();var repository=Cheap(directory);var original=repository.DatabasePath;SqliteConnection.ClearAllPools(); File.WriteAllText(directory.File("settings.json"),"preserve me");File.WriteAllText(original+".active","../settings.json"); Check.That(repository.GetCacheUsage().Files>=2); var result=await repository.ClearAsync();Check.That(result.Succeeded,result.Message); Check.Equal("preserve me",File.ReadAllText(directory.File("settings.json")));Check.Equal(original,repository.DatabasePath);Check.Equal(0L,repository.Statistics().Quotes); } [Test]public void FailedReplacementRollsBackEveryQuarantinedOriginal() { using var directory=new TemporaryDirectory();var repository=Cheap(directory);SqliteConnection.ClearAllPools(); var original=repository.DatabasePath;var before=File.ReadAllBytes(original);File.WriteAllText(repository.VerifiedBackupPath,"old verified backup"); var cleanup=typeof(MarketRepository).Assembly.GetType("XFEEDToolBox.Core.Data.MarketCacheCleanup")!; var clear=cleanup.GetMethod("Clear",BindingFlags.NonPublic|BindingFlags.Static)!; var result=(MarketCacheClearResult)clear.Invoke(null,[original,(Action)(()=>throw new InvalidDataException("fixture replacement refused")),(Action)SqliteConnection.ClearAllPools,CancellationToken.None])!; Check.That(!result.Succeeded);Check.That(File.ReadAllBytes(original).SequenceEqual(before));Check.Equal("old verified backup",File.ReadAllText(repository.VerifiedBackupPath)); Check.That(!Directory.EnumerateDirectories(directory.Path,"markets.db.cleanup-*").Any()); } [Test]public async Task PublicFilteringRunsBeforeTheGateAndAlwaysRetainsLocalJournalObservations() { using var directory=new TemporaryDirectory();var repository=Cheap(directory); repository.PublicDataFilter=station=>{_ = repository.GetStation(station.MarketId);return station.Position?.X<=150;}; await repository.SaveStationAsync(Check.Station(1,1000));Check.That(repository.GetStation(1) is null); await repository.SaveStationAsync(Check.Station(2,1000),localObservation:true);Check.That(repository.GetStation(2) is not null); Check.That(!await repository.SaveSnapshotAsync(new(Check.Station(3,1000),[Check.Quote(3,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Spansh))); Check.That(repository.GetStation(3) is null); Check.That(await repository.SaveSnapshotAsync(new(Check.Station(4,1000),[Check.Quote(4,"water",10,9)],DateTimeOffset.UtcNow,DataSource.Journal))); Check.That(await repository.SaveSnapshotAsync(new(Check.Station(5,10),[Check.Quote(5,"food",20,19)],DateTimeOffset.UtcNow,DataSource.Capture))); Check.Equal(2L,repository.Statistics().Quotes); } [Test]public void PostCommitPurgeFailureNeverRemovesTheReplacementOrRestoresDeletedOldFiles() { using var directory=new TemporaryDirectory();var original=directory.File("markets.db");File.WriteAllText(original,"old cache");File.WriteAllText(original+".verified.db","old backup"); var cleanup=typeof(MarketRepository).Assembly.GetType("XFEEDToolBox.Core.Data.MarketCacheCleanup")!; var clear=cleanup.GetMethod("Clear",BindingFlags.NonPublic|BindingFlags.Static)!;string? extra=null; Action createEmpty=()=> { File.WriteAllText(original,"new smaller cache"); var quarantine=Directory.EnumerateDirectories(directory.Path,"markets.db.cleanup-*").Single(); extra=Path.Combine(quarantine,"unexpected-empty-child");Directory.CreateDirectory(extra); }; var result=(MarketCacheClearResult)clear.Invoke(null,[original,createEmpty,(Action)(()=>{}),CancellationToken.None])!; Check.That(result.Succeeded,result.Message);Check.Equal("new smaller cache",File.ReadAllText(original));Check.That(!File.Exists(original+".verified.db")); Check.That(Directory.Exists(extra));Check.Equal(0L,result.FreedBytes); } }