Files
2026-07-13 12:31:29 +08:00

964 lines
42 KiB
C#

using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core.Tests;
public sealed class CoreIntegrationTests
{
[Fact]
public async Task RunSync_RewritesRolloutFilesAndSqlite_ThenRestoreRevertsBoth()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteRolloutAsync(archivedPath, "thread-b", "newapi");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false),
("thread-b", "newapi", true)
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal("openai", syncResult.TargetProvider);
Assert.Equal(2, syncResult.ChangedSessionFiles);
Assert.Empty(syncResult.SkippedLockedRolloutFiles);
Assert.Empty(syncResult.SkippedUnreadableRolloutFiles);
Assert.Equal(2, syncResult.SqliteRowsUpdated);
BackupMetadataFile backupMetadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(Path.Combine(syncResult.BackupDir, "metadata.json")),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
Assert.Equal(
[
Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename)
],
backupMetadata.DbFiles);
string syncedSession = await File.ReadAllTextAsync(sessionPath);
string syncedArchived = await File.ReadAllTextAsync(archivedPath);
Assert.Contains("\"model_provider\":\"openai\"", syncedSession);
Assert.Contains("\"model_provider\":\"openai\"", syncedArchived);
await using (SqliteConnection connection = fixture.OpenSqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal(
[
("thread-a", "openai"),
("thread-b", "openai")
], rows);
}
RestoreResult restoreResult = await service.RunRestoreAsync(fixture.CodexHome, syncResult.BackupDir);
Assert.Equal("openai", restoreResult.TargetProvider);
string restoredSession = await File.ReadAllTextAsync(sessionPath);
string restoredArchived = await File.ReadAllTextAsync(archivedPath);
Assert.Contains("\"model_provider\":\"apigather\"", restoredSession);
Assert.Contains("\"model_provider\":\"newapi\"", restoredArchived);
}
[Fact]
public async Task RunSync_UpdatesLegacyRootSqliteDatabase_WhenSqliteDirStateIsStale()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-active-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-active-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-active-a", "dal");
await fixture.WriteRolloutAsync(archivedPath, "thread-active-b", "dal");
await fixture.WriteStateDbAsync(
[
("thread-active-a", "dal", false)
]);
await fixture.WriteLegacyStateDbAsync(
[
("thread-active-a", "dal", false),
("thread-active-b", "dal", true)
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(2, syncResult.SqliteRowsUpdated);
BackupMetadataFile backupMetadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(Path.Combine(syncResult.BackupDir, "metadata.json")),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
Assert.Equal([AppConstants.DbFileBasename], backupMetadata.DbFiles);
await using (SqliteConnection connection = fixture.OpenLegacySqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal(
[
("thread-active-a", "openai"),
("thread-active-b", "openai")
], rows);
}
await using (SqliteConnection connection = fixture.OpenSqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal([("thread-active-a", "dal")], rows);
}
}
[Fact]
public async Task RunSwitch_UpdatesConfigAndSyncsProviderMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "openai");
await fixture.WriteStateDbAsync(
[
("thread-a", "openai", false)
]);
CodexSyncService service = new();
SyncResult result = await service.RunSwitchAsync(fixture.CodexHome, "apigather");
Assert.Equal("apigather", result.TargetProvider);
Assert.True(result.ConfigUpdated);
string configText = await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml"));
Assert.Contains("model_provider = \"apigather\"", configText);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task RunSync_RepairsSqliteHasUserEventFromRolloutUserMessages()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "openai");
await fixture.WriteStateDbWithUserEventColumnAsync(
[
("thread-a", "openai", false, false)
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteUserEventRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT has_user_event FROM threads WHERE id = 'thread-a'";
long hasUserEvent = (long)(await command.ExecuteScalarAsync())!;
Assert.Equal(1, hasUserEvent);
}
[Fact]
public async Task RunSync_RepairsSqliteCwdFromRolloutSessionMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-cwd.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-cwd",
"openai",
@"D:\GitHubProject\oss-maintainer-hub");
await fixture.WriteStateDbWithCwdAsync(
[
("thread-cwd", "openai", false, @"\\?\D:\GitHubProject\oss-maintainer-hub")
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteCwdRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT cwd FROM threads WHERE id = 'thread-cwd'";
string cwd = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal(@"D:\GitHubProject\oss-maintainer-hub", cwd);
}
[Fact]
public async Task RunSync_NormalizesExtendedRolloutCwd_BeforeRepairingSqlite()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-cwd-extended.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-cwd-extended",
"openai",
@"\\?\E:\GitHubProject\lin-framework");
await fixture.WriteStateDbWithCwdAsync(
[
("thread-cwd-extended", "openai", false, @"\\?\E:\GitHubProject\lin-framework")
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteCwdRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT cwd FROM threads WHERE id = 'thread-cwd-extended'";
string cwd = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal(@"E:\GitHubProject\lin-framework", cwd);
}
[Fact]
public async Task RunSync_RestoresWorkspaceRootsFromProjectOrder_NormalizesForDesktop_AndRestoreRevertsGlobalState()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
await fixture.WriteGlobalStateAsync(new Dictionary<string, object?>
{
["electron-saved-workspace-roots"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync"
},
["project-order"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync",
@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"
},
["active-workspace-roots"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync"
},
["electron-workspace-root-labels"] = new Dictionary<string, string>
{
[@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"] = "BrainLifeAssets"
}
});
await fixture.WriteStateDbWithCwdAsync(
[
("thread-a", "openai", false, @"\\?\D:\GitHubProject\codex-provider-sync"),
("thread-b", "openai", false, @"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets")
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(2, syncResult.UpdatedWorkspaceRoots);
JsonDocument syncedState = JsonDocument.Parse(
await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename)));
Assert.Equal(
[
@"D:\GitHubProject\codex-provider-sync",
@"E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
syncedState.RootElement.GetProperty("electron-saved-workspace-roots").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
[
@"D:\GitHubProject\codex-provider-sync",
@"E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
syncedState.RootElement.GetProperty("project-order").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
@"D:\GitHubProject\codex-provider-sync",
syncedState.RootElement.GetProperty("active-workspace-roots")[0].GetString());
Assert.Equal(
"BrainLifeAssets",
syncedState.RootElement.GetProperty("electron-workspace-root-labels")
.GetProperty(@"E:\NewRich\BrainLife\Code\BrainLife\Assets")
.GetString());
await service.RunRestoreAsync(fixture.CodexHome, syncResult.BackupDir);
JsonDocument restoredState = JsonDocument.Parse(
await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename)));
Assert.Equal(
[
@"\\?\D:\GitHubProject\codex-provider-sync"
],
restoredState.RootElement.GetProperty("electron-saved-workspace-roots").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
[
@"\\?\D:\GitHubProject\codex-provider-sync",
@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
restoredState.RootElement.GetProperty("project-order").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
}
[Fact]
public async Task GetStatus_ReportsImplicitDefaultProviderAndCounts()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteRolloutAsync(archivedPath, "thread-b", "openai");
long backupOneBytes = await fixture.WriteBackupAsync("20260319T000000000Z", ("note.txt", "backup-one"));
long backupTwoBytes = await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "backup-two"));
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false),
("thread-b", "openai", true)
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal("openai", status.CurrentProvider.Provider);
Assert.True(status.CurrentProvider.Implicit);
Assert.Equal(1, status.RolloutCounts.Sessions["apigather"]);
Assert.Equal(1, status.SqliteCounts!.ArchivedSessions["openai"]);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("sqlite-dir", status.StateDbLocation!.Source);
Assert.Equal(fixture.StateDbPath(), status.StateDbLocation.Path);
Assert.Equal(2, status.BackupSummary.Count);
Assert.Equal(backupOneBytes + backupTwoBytes, status.BackupSummary.TotalBytes);
Assert.Contains($"database: {fixture.StateDbPath()}", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_FallsBackToLegacyRootSqliteDatabase()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
await using (SqliteConnection connection = fixture.OpenLegacySqliteConnection())
{
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
archived INTEGER NOT NULL DEFAULT 0
)
""";
await create.ExecuteNonQueryAsync();
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = "INSERT INTO threads (id, model_provider, archived) VALUES ('legacy-thread', 'openai', 0)";
await insert.ExecuteNonQueryAsync();
}
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("legacy-root", status.StateDbLocation!.Source);
Assert.Equal(fixture.LegacyStateDbPath(), status.StateDbLocation.Path);
Assert.Equal(1, status.SqliteCounts!.Sessions["openai"]);
Assert.Contains("legacy root", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_ChoosesLegacyRootSqliteDatabase_WhenSqliteDirStateIsStale()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
await fixture.WriteRolloutAsync(
fixture.RolloutPath("sessions", "rollout-active-a.jsonl"),
"thread-active-a",
"openai");
await fixture.WriteRolloutAsync(
fixture.RolloutPath("sessions", "rollout-active-b.jsonl"),
"thread-active-b",
"openai");
await fixture.WriteRolloutAsync(
fixture.RolloutPath("archived_sessions", "rollout-active-c.jsonl"),
"thread-active-c",
"openai");
await fixture.WriteStateDbAsync(
[
("thread-active-a", "custom", false)
]);
await fixture.WriteLegacyStateDbAsync(
[
("thread-active-a", "openai", false),
("thread-active-b", "openai", false),
("thread-active-c", "openai", true)
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("legacy-root", status.StateDbLocation!.Source);
Assert.Equal(fixture.LegacyStateDbPath(), status.StateDbLocation.Path);
Assert.Equal(2, status.SqliteCounts!.Sessions["openai"]);
Assert.Equal(1, status.SqliteCounts.ArchivedSessions["openai"]);
Assert.Contains("legacy root", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_ReportsPendingSqliteUserEventAndCwdRepairs()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-repair-status.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-repair-status",
"openai",
@"E:\GitHubProject\lin-framework");
await fixture.WriteStateDbWithUserEventAndCwdAsync(
[
("thread-repair-status", "openai", false, false, @"\\?\E:\GitHubProject\lin-framework")
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.SqliteRepairStats);
Assert.Equal(1, status.SqliteRepairStats!.UserEventRowsNeedingRepair);
Assert.Equal(1, status.SqliteRepairStats.CwdRowsNeedingRepair);
string formatted = TextFormatter.FormatStatus(status);
Assert.Contains("user-event flags needing repair: 1", formatted);
Assert.Contains("cwd paths needing repair: 1", formatted);
}
[Fact]
public async Task GetStatus_ReportsProjectVisibilityRanksAndCwdExactMatchDiagnostics()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"dal\"");
await fixture.WriteGlobalStateAsync(new Dictionary<string, object?>
{
["electron-saved-workspace-roots"] = new[]
{
@"E:\GitHubProject\lin-framework"
}
});
List<(string Id, string ModelProvider, string Cwd, string Source, bool Archived, string FirstUserMessage, long UpdatedAtMs)> rows = [];
for (int index = 0; index < 51; index += 1)
{
rows.Add(($"thread-other-{index:00}", "dal", @"D:\OtherProject", "cli", false, "hello", 1000 - index));
}
rows.Add(("thread-lin", "dal", @"\\?\E:\GitHubProject\lin-framework", "cli", false, "hello", 1));
await fixture.WriteStateDbForProjectVisibilityAsync(rows);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
ProjectThreadVisibility project = Assert.Single(status.ProjectThreadVisibility);
Assert.Equal(@"E:\GitHubProject\lin-framework", project.Root);
Assert.Equal(1, project.InteractiveThreads);
Assert.Equal(0, project.FirstPageThreads);
Assert.Equal([52], project.Ranks);
Assert.Equal(0, project.ExactCwdMatches);
Assert.Equal(1, project.VerbatimCwdRows);
string formatted = TextFormatter.FormatStatus(status);
Assert.Contains("Project visibility:", formatted);
Assert.Contains("first page 0/50, ranks 52, exact cwd 0/1, verbatim cwd 1", formatted);
}
[Fact]
public async Task RunSwitch_RejectsUnknownCustomProviders()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
CodexSyncService service = new();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.RunSwitchAsync(fixture.CodexHome, "missing"));
Assert.Contains("Provider \"missing\" is not available", error.Message);
}
[Fact]
public async Task RunSync_LeavesRolloutsUntouched_WhenSqliteIsLocked()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand begin = connection.CreateCommand();
begin.CommandText = "BEGIN IMMEDIATE";
await begin.ExecuteNonQueryAsync();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0));
Assert.Contains("state_5.sqlite is currently in use", error.Message);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task RunSync_SkipsLockedRolloutFiles_AndStillUpdatesSqlite()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
SyncResult result;
using (FileStream lockStream = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
result = await service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0);
}
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal([sessionPath], result.SkippedLockedRolloutFiles);
Assert.Empty(result.SkippedUnreadableRolloutFiles);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT model_provider FROM threads WHERE id = 'thread-a'";
string provider = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal("openai", provider);
}
[Fact]
public async Task ApplySessionChanges_SkipsFile_WhenRolloutChangesAfterCollection()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
await File.AppendAllTextAsync(
sessionPath,
"{\"timestamp\":\"2026-03-19T00:00:01.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"assistant_message\",\"message\":\"later\"}}\n");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(0, result.AppliedCount);
Assert.Equal([sessionPath], result.SkippedPaths);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
Assert.Contains("\"message\":\"later\"", rollout);
}
[Fact]
public async Task ApplySessionChanges_RewritesFile_WhenRolloutIsUnchanged()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(1, result.AppliedCount);
Assert.Empty(result.SkippedPaths);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"openai\"", rollout);
}
[Fact]
public async Task RestoreBackup_OnlyRestoresRolloutFilesThatWereActuallyApplied()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string configPath = Path.Combine(fixture.CodexHome, "config.toml");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService sessionService = new();
SessionChangeCollection collected = await sessionService.CollectSessionChangesAsync(fixture.CodexHome, "openai");
BackupService backupService = new(sessionService, new SqliteStateService());
string backupDir = await backupService.CreateBackupAsync(
fixture.CodexHome,
"openai",
collected.Changes,
configPath);
await backupService.UpdateSessionBackupManifestAsync(backupDir, []);
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "manual");
await backupService.RestoreBackupAsync(
backupDir,
fixture.CodexHome,
new RestoreBackupOptions
{
RestoreConfig = false,
RestoreDatabase = false,
RestoreSessions = true
});
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"manual\"", rollout);
}
[Fact]
public async Task RunSync_SkipsRolloutFile_WhenAnotherWriterAllowsSharing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
SyncResult result;
using (FileStream writer = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
{
result = await service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0);
}
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal([sessionPath], result.SkippedLockedRolloutFiles);
Assert.Empty(result.SkippedUnreadableRolloutFiles);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task Status_SkipsLockedRolloutFile_WhenAnotherWriterAllowsSharing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-status-locked.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-status-locked", "openai");
CodexSyncService service = new();
using FileStream writer = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete);
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal([sessionPath], status.LockedRolloutFiles);
Assert.Empty(status.UnreadableRolloutFiles);
Assert.Contains("Locked rollout files skipped during status scan: 1", TextFormatter.FormatStatus(status));
}
[Fact]
public void FormatStatus_ReportsUnreadableRolloutFiles()
{
StatusSnapshot status = new()
{
CodexHome = @"C:\Users\test\.codex",
CurrentProvider = new CurrentProviderInfo("openai", false),
ConfiguredProviders = ["openai"],
RolloutCounts = new ProviderCounts(),
LockedRolloutFiles = [],
UnreadableRolloutFiles = [@"C:\Users\test\.codex\sessions\rollout-bad.jsonl"],
EncryptedContentCounts = new ProviderCounts(),
SqliteCounts = null,
BackupRoot = @"C:\Users\test\.codex\backups_state\provider-sync",
BackupSummary = new BackupSummary
{
Count = 0,
TotalBytes = 0
}
};
Assert.Contains("Unreadable rollout files skipped during status scan: 1", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task RunPruneBackups_RemovesOldestBackupDirectories()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
long oldestBytes = await fixture.WriteBackupAsync(
"20260319T000000000Z",
("note.txt", "oldest"),
("db/state_5.sqlite", "sqlite"));
await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "middle"));
await fixture.WriteBackupAsync("20260321T000000000Z", ("note.txt", "newest"));
CodexSyncService service = new();
BackupPruneResult result = await service.RunPruneBackupsAsync(fixture.CodexHome, 2);
Assert.Equal(fixture.BackupRoot(), result.BackupRoot);
Assert.Equal(1, result.DeletedCount);
Assert.Equal(2, result.RemainingCount);
Assert.Equal(oldestBytes, result.FreedBytes);
Assert.False(Directory.Exists(fixture.BackupPath("20260319T000000000Z")));
Assert.True(Directory.Exists(fixture.BackupPath("20260320T000000000Z")));
Assert.True(Directory.Exists(fixture.BackupPath("20260321T000000000Z")));
}
[Fact]
public async Task RunPruneBackups_IgnoresDirectoriesWithoutManagedBackupMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
await fixture.WriteBackupAsync(
"20260320T000000000Z",
("metadata.json", $$"""
{
"version": 1,
"namespace": "provider-sync",
"codexHome": "{{fixture.CodexHome.Replace("\\", "\\\\")}}",
"targetProvider": "openai",
"createdAt": "2026-03-24T00:00:00.0000000+00:00",
"dbFiles": [],
"changedSessionFiles": 0
}
"""));
string junkDirectory = fixture.BackupPath("manual-notes");
Directory.CreateDirectory(junkDirectory);
await File.WriteAllTextAsync(Path.Combine(junkDirectory, "readme.txt"), "keep me");
CodexSyncService service = new();
BackupPruneResult result = await service.RunPruneBackupsAsync(fixture.CodexHome, 0);
Assert.Equal(1, result.DeletedCount);
Assert.Equal(0, result.RemainingCount);
Assert.True(Directory.Exists(junkDirectory));
}
[Fact]
public async Task RunSync_AutoPrunesBackupsToDefaultRetention()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
for (int index = 0; index < AppConstants.DefaultBackupRetentionCount; index += 1)
{
await fixture.WriteBackupAsync(
$"20240101T0000{index:00}000Z",
("note.txt", $"backup-{index}"));
}
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
string[] backupDirs = Directory.GetDirectories(fixture.BackupRoot());
Assert.Equal(AppConstants.DefaultBackupRetentionCount, backupDirs.Length);
Assert.True(Directory.Exists(result.BackupDir));
Assert.NotNull(result.AutoPruneResult);
Assert.Equal(1, result.AutoPruneResult!.DeletedCount);
Assert.Equal(AppConstants.DefaultBackupRetentionCount, result.AutoPruneResult.RemainingCount);
Assert.True(string.IsNullOrWhiteSpace(result.AutoPruneWarning));
}
[Fact]
public async Task RunSync_UsesCustomAutomaticBackupRetentionCount()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
for (int index = 0; index < 4; index += 1)
{
await fixture.WriteBackupAsync(
$"20240101T0000{index:00}000Z",
("note.txt", $"backup-{index}"));
}
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome, keepCount: 2);
string[] backupDirs = Directory.GetDirectories(fixture.BackupRoot());
Assert.Equal(2, backupDirs.Length);
Assert.True(Directory.Exists(result.BackupDir));
Assert.NotNull(result.AutoPruneResult);
Assert.Equal(3, result.AutoPruneResult!.DeletedCount);
Assert.Equal(2, result.AutoPruneResult.RemainingCount);
}
[Fact]
public async Task ApplySessionChanges_RestoresOriginalLastWriteTime()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string sessionPath = fixture.RolloutPath("sessions", "rollout-mtime.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-mtime", "apigather");
DateTime originalTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
File.SetLastWriteTimeUtc(sessionPath, originalTime);
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(1, result.AppliedCount);
Assert.Equal(originalTime, File.GetLastWriteTimeUtc(sessionPath));
}
[Fact]
public async Task Status_ReportsEncryptedContentCountsAndWarning()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-enc.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-enc", "apigather");
await fixture.AppendEncryptedContentAsync(sessionPath);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal(1, status.EncryptedContentCounts.Sessions["apigather"]);
Assert.Contains("invalid_encrypted_content", status.EncryptedContentWarning);
}
[Fact]
public async Task CollectSessionChanges_StreamsLargeRolloutContent()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string sessionPath = fixture.RolloutPath("sessions", "rollout-streamed.jsonl");
object payload = new
{
id = "thread-streamed",
timestamp = "2026-03-19T00:00:00.000Z",
cwd = "C:\\AITemp",
source = "cli",
cli_version = "0.115.0",
model_provider = "apigather"
};
string firstLine = JsonSerializer.Serialize(new
{
timestamp = "2026-03-19T00:00:00.000Z",
type = "session_meta",
payload
});
await File.WriteAllTextAsync(sessionPath, firstLine + "\n");
const int chunkBytes = 1024 * 1024;
const string tokenPrefix = "encrypted_";
string userEvent = JsonSerializer.Serialize(new
{
type = "event_msg",
payload = new
{
type = "user_message",
message = "after large content"
}
});
await File.AppendAllTextAsync(
sessionPath,
$"{new string('x', chunkBytes - tokenPrefix.Length)}{tokenPrefix}content\n{userEvent}\n");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
Assert.Equal(1, collected.EncryptedContentCounts.Sessions["apigather"]);
Assert.Contains("thread-streamed", collected.UserEventThreadIds);
}
[Fact]
public async Task Status_ReturnsMalformedSqliteAsUnreadable()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
Directory.CreateDirectory(Path.GetDirectoryName(fixture.StateDbPath())!);
await File.WriteAllTextAsync(fixture.StateDbPath(), "not sqlite");
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.True(status.SqliteCounts!.Unreadable);
Assert.Contains("malformed", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task RestoreBackup_CanSkipConfigDatabaseAndSessions()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-skip.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-skip", "apigather");
SessionRolloutService sessionService = new();
SessionChangeCollection collected = await sessionService.CollectSessionChangesAsync(fixture.CodexHome, "openai");
BackupService backupService = new(sessionService, new SqliteStateService());
string backupDir = await backupService.CreateBackupAsync(
fixture.CodexHome,
"openai",
collected.Changes,
Path.Combine(fixture.CodexHome, "config.toml"));
await fixture.WriteConfigAsync("model_provider = \"manual\"");
await fixture.WriteRolloutAsync(sessionPath, "thread-skip", "manual");
await backupService.RestoreBackupAsync(
backupDir,
fixture.CodexHome,
new RestoreBackupOptions
{
RestoreConfig = false,
RestoreDatabase = false,
RestoreSessions = false
});
Assert.Contains("model_provider = \"manual\"", await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml")));
Assert.Contains("\"model_provider\":\"manual\"", await File.ReadAllTextAsync(sessionPath));
}
}