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<SqliteException>(()=>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);
}
}
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<SqliteException>(()=>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);
}
}