Refactor smoke tests for SQLCipher-first backend contract

This commit is contained in:
Jacob Schmidt 2026-02-28 18:58:44 -06:00
parent 941cafba39
commit 4fd3c5b5f1
6 changed files with 701 additions and 820 deletions

View File

@ -10,14 +10,8 @@ internal static partial class Program
} }
static Task TestDatabaseSchemaParityAsync() static Task TestDatabaseSchemaParityAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{ {
var service = NewDatabaseService(); var service = NewDatabaseService();
var schemaPath = service.WriteSchemaBootstrap(root);
var statements = service.GetSchemaStatements(); var statements = service.GetSchemaStatements();
var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase); var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase);
@ -26,37 +20,25 @@ internal static partial class Program
Assert(tableNames.Contains("fragments"), "Schema should contain fragments table."); Assert(tableNames.Contains("fragments"), "Schema should contain fragments table.");
Assert(tableNames.Contains("tags"), "Schema should contain tags table."); Assert(tableNames.Contains("tags"), "Schema should contain tags table.");
Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table."); Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table.");
Assert(tableNames.Contains("entry_documents"), "Schema should contain entry_documents table.");
Assert(File.Exists(schemaPath), "Schema bootstrap file should be written.");
var fragmentTagsSql = statements["fragment_tags"]; var fragmentTagsSql = statements["fragment_tags"];
Assert(fragmentTagsSql.Contains("PRIMARY KEY (fragment_id, tag_id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should enforce composite primary key parity."); Assert(fragmentTagsSql.Contains("PRIMARY KEY (fragment_id, tag_id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should enforce composite primary key parity.");
Assert(fragmentTagsSql.Contains("FOREIGN KEY (fragment_id) REFERENCES fragments (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain fragment foreign key parity."); Assert(fragmentTagsSql.Contains("FOREIGN KEY (fragment_id) REFERENCES fragments (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain fragment foreign key parity.");
Assert(fragmentTagsSql.Contains("FOREIGN KEY (tag_id) REFERENCES tags (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain tag foreign key parity."); Assert(fragmentTagsSql.Contains("FOREIGN KEY (tag_id) REFERENCES tags (id)", StringComparison.OrdinalIgnoreCase), "fragment_tags should contain tag foreign key parity.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask; return Task.CompletedTask;
} }
static async Task TestEntryDatabaseStatusAsync() static async Task TestEntryDatabaseStatusAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); var entry = NewEntry(unlocked: false);
Directory.CreateDirectory(root);
try
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "db.status", action = "db.status",
payload = new payload = new
{ {
password = "vault-pass-123", password = "vault-pass-123"
dataDirectory = root
} }
}); });
@ -70,72 +52,42 @@ internal static partial class Program
Assert(string.Equals(keyDerivation.GetString(), "PBKDF2-HMAC-SHA256", StringComparison.Ordinal), "Expected PBKDF2-HMAC-SHA256 key derivation."); Assert(string.Equals(keyDerivation.GetString(), "PBKDF2-HMAC-SHA256", StringComparison.Ordinal), "Expected PBKDF2-HMAC-SHA256 key derivation.");
Assert(data.TryGetProperty("SchemaTables", out var schemaTables), "Expected SchemaTables list in db.status payload."); Assert(data.TryGetProperty("SchemaTables", out var schemaTables), "Expected SchemaTables list in db.status payload.");
Assert(schemaTables.ValueKind == JsonValueKind.Array && schemaTables.GetArrayLength() >= 5, "Expected schema table list in db.status payload."); Assert(schemaTables.ValueKind == JsonValueKind.Array && schemaTables.GetArrayLength() >= 5, "Expected schema table list in db.status payload.");
Assert(data.TryGetProperty("SchemaBootstrapPath", out var schemaBootstrapPath), "Expected SchemaBootstrapPath in db.status payload.");
Assert(schemaBootstrapPath.ValueKind == JsonValueKind.String && File.Exists(schemaBootstrapPath.GetString()), "Expected db.status to emit existing schema bootstrap file path.");
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in db.status payload."); Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in db.status payload.");
Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected SQLCipher runtime-ready status in db.status payload."); Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected SQLCipher runtime-ready status in db.status payload.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryDatabaseInitializeSchemaAsync() static async Task TestEntryDatabaseInitializeSchemaAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); var entry = NewEntry(unlocked: false);
Directory.CreateDirectory(root);
try
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "db.initialize_schema", action = "db.initialize_schema",
payload = new payload = new
{ {
password = "vault-pass-123", password = "vault-pass-123"
dataDirectory = root
} }
}); });
var response = await entry.HandleCommandAsync(request); var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response); using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema."); Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema.");
var data = doc.RootElement.GetProperty("data"); var data = doc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("schemaPath", out var schemaPath), "Expected schemaPath in db.initialize_schema response."); Assert(data.TryGetProperty("initialized", out var initialized), "Expected initialized flag in db.initialize_schema response.");
Assert(schemaPath.ValueKind == JsonValueKind.String, "Expected string schemaPath value."); Assert(initialized.ValueKind == JsonValueKind.True, "Expected initialized=true from db.initialize_schema.");
var resolvedPath = schemaPath.GetString() ?? ""; Assert(data.TryGetProperty("databasePath", out var databasePath), "Expected databasePath in db.initialize_schema response.");
Assert(File.Exists(resolvedPath), "db.initialize_schema should write schema bootstrap file."); Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty databasePath from db.initialize_schema.");
var schemaText = File.ReadAllText(resolvedPath);
Assert(schemaText.Contains("CREATE TABLE IF NOT EXISTS entries", StringComparison.OrdinalIgnoreCase), "schema bootstrap should include entries table.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
} }
static async Task TestEntryDatabaseHydrateWorkspaceAsync() static async Task TestEntryDatabaseHydrateWorkspaceAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); var entry = NewEntry(unlocked: false);
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-20.md"), "one");
File.WriteAllText(Path.Combine(root, "2026-02-21.md"), "two");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "db.hydrate_workspace", action = "db.hydrate_workspace",
payload = new payload = new
{ {
password = "vault-pass-123", password = "vault-pass-123"
dataDirectory = root
} }
}); });
@ -146,30 +98,21 @@ internal static partial class Program
Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in hydrate payload."); Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in hydrate payload.");
Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath."); Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath.");
Assert(data.TryGetProperty("SchemaBootstrapPath", out var schemaPath), "Expected SchemaBootstrapPath in hydrate payload.");
Assert(schemaPath.ValueKind == JsonValueKind.String && File.Exists(schemaPath.GetString()), "Expected hydrate to write schema bootstrap file.");
Assert(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload."); Assert(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload.");
Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() == 2, "Expected hydrate to count markdown files in workspace."); Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() >= 0, "Expected non-negative EntryFilesProcessed in hydrate payload.");
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload."); Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload.");
Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds."); Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static Task TestConfigServiceParityKeysAsync() static Task TestConfigServiceParityKeysAsync()
{ {
IJournalConfigService config = new JournalConfigService(); IJournalConfigService config = NewConfigService();
var current = config.Current; var current = config.Current;
Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty."); Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty."); Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.DataDirectory), "Config DataDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty."); Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty.");
Assert(current.MonthlyVaultFormat == "%Y-%m.vault", "Config MonthlyVaultFormat should match Python format token."); Assert(!string.IsNullOrWhiteSpace(current.DatabaseFilename), "Config DatabaseFilename should not be empty.");
Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch."); Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch.");
Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch."); Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch.");
@ -194,10 +137,10 @@ internal static partial class Program
var data = doc.RootElement.GetProperty("data"); var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get."); Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get.");
Assert(data.TryGetProperty("DataDirectory", out var dataDirectory), "Expected DataDirectory in config payload."); Assert(data.TryGetProperty("VaultDirectory", out var vaultDirectory), "Expected VaultDirectory in config payload.");
Assert(dataDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(dataDirectory.GetString()), "Expected non-empty DataDirectory value."); Assert(vaultDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(vaultDirectory.GetString()), "Expected non-empty VaultDirectory value.");
Assert(data.TryGetProperty("MonthlyVaultFormat", out var monthlyVaultFormat), "Expected MonthlyVaultFormat in config payload."); Assert(data.TryGetProperty("DatabaseFilename", out var databaseFilename), "Expected DatabaseFilename in config payload.");
Assert(monthlyVaultFormat.GetString() == "%Y-%m.vault", "Expected Python-compatible MonthlyVaultFormat value."); Assert(databaseFilename.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databaseFilename.GetString()), "Expected non-empty DatabaseFilename value.");
Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload."); Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload.");
Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload."); Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload.");
} }
@ -233,7 +176,7 @@ internal static partial class Program
{ {
action = "entries.save", action = "entries.save",
mode = "Daily", mode = "Daily",
filePath = "E:/journal/2026-02-24.md" filePath = "db://entry/2026-02-24.md"
}); });
var redacted = LogRedactor.RedactPayload(payload); var redacted = LogRedactor.RedactPayload(payload);
@ -246,4 +189,3 @@ internal static partial class Program
return Task.CompletedTask; return Task.CompletedTask;
} }
} }

View File

@ -47,25 +47,19 @@ internal static partial class Program
static async Task TestEntryEntriesSaveMergeAsync() static async Task TestEntryEntriesSaveMergeAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); var entry = NewEntry();
Directory.CreateDirectory(root); var firstPath = await SaveEntryForTestAsync(entry, "2026-02-22", """
try
{
var filePath = Path.Combine(root, "2026-02-22.md");
File.WriteAllText(filePath, """
Date: 2026-02-22 Date: 2026-02-22
## Summary ## Summary
old summary text old summary text
"""); """);
var entry = NewEntry(); var mergeRequest = JsonSerializer.Serialize(new
var request = JsonSerializer.Serialize(new
{ {
action = "entries.save", action = "entries.save",
payload = new payload = new
{ {
filePath, filePath = firstPath,
mode = "Daily", mode = "Daily",
content = """ content = """
Date: 2026-02-22 Date: 2026-02-22
@ -77,21 +71,21 @@ new reflection text
} }
}); });
var response = await entry.HandleCommandAsync(request); var mergeResponse = await entry.HandleCommandAsync(mergeRequest);
using var doc = JsonDocument.Parse(response); using var mergeDoc = JsonDocument.Parse(mergeResponse);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save."); Assert(mergeDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save daily merge.");
var saved = File.ReadAllText(filePath); var loadedAfterMerge = await LoadEntryForTestAsync(entry, firstPath);
Assert(saved.Contains("new summary text", StringComparison.Ordinal), "Expected merged file to contain new summary text."); Assert(loadedAfterMerge.Contains("new summary text", StringComparison.Ordinal), "Expected merged entry to contain new summary text.");
Assert(!saved.Contains("old summary text", StringComparison.Ordinal), "Expected merged file to replace old summary section."); Assert(!loadedAfterMerge.Contains("old summary text", StringComparison.Ordinal), "Expected merged entry to replace old summary section.");
Assert(saved.Contains("new reflection text", StringComparison.Ordinal), "Expected merged file to contain new reflection section."); Assert(loadedAfterMerge.Contains("new reflection text", StringComparison.Ordinal), "Expected merged entry to contain new reflection section.");
var fragmentSaveRequest = JsonSerializer.Serialize(new var fragmentSaveRequest = JsonSerializer.Serialize(new
{ {
action = "entries.save", action = "entries.save",
payload = new payload = new
{ {
filePath, filePath = firstPath,
mode = "Fragment", mode = "Fragment",
content = "!NOTE\nfragment append text" content = "!NOTE\nfragment append text"
} }
@ -100,32 +94,21 @@ new reflection text
var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest); var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest);
using var fragmentDoc = JsonDocument.Parse(fragmentResponse); using var fragmentDoc = JsonDocument.Parse(fragmentResponse);
Assert(fragmentDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save fragment mode."); Assert(fragmentDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save fragment mode.");
var appended = File.ReadAllText(filePath);
Assert(appended.Contains("fragment append text", StringComparison.Ordinal), "Expected fragment append text in saved file."); var loadedAfterFragment = await LoadEntryForTestAsync(entry, firstPath);
} Assert(loadedAfterFragment.Contains("fragment append text", StringComparison.Ordinal), "Expected fragment append text in saved entry.");
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
} }
static async Task TestEntryEntriesLoadAsync() static async Task TestEntryEntriesLoadAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); var entry = NewEntry();
Directory.CreateDirectory(root);
try
{
var filePath = Path.Combine(root, "2026-02-22.md");
var content = """ var content = """
Date: 2026-02-22 Date: 2026-02-22
## Summary ## Summary
hello world hello world
"""; """;
File.WriteAllText(filePath, content); var filePath = await SaveEntryForTestAsync(entry, "2026-02-22", content);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "entries.load", action = "entries.load",
@ -145,32 +128,17 @@ hello world
Assert(entryDto.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load."); Assert(entryDto.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load.");
Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load."); Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryEntriesListAsync() static async Task TestEntryEntriesListAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "c");
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "a");
File.WriteAllText(Path.Combine(root, "ignore.txt"), "x");
var entry = NewEntry(); var entry = NewEntry();
await SaveEntryForTestAsync(entry, "2026-02-03", "c");
await SaveEntryForTestAsync(entry, "2026-02-01", "a");
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "entries.list", action = "entries.list",
payload = new payload = new { }
{
dataDirectory = root
}
}); });
var response = await entry.HandleCommandAsync(request); var response = await entry.HandleCommandAsync(request);
@ -179,51 +147,36 @@ hello world
var data = doc.RootElement.GetProperty("data"); var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array."); Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array.");
Assert(data.GetArrayLength() == 2, "Expected entries.list to return only markdown files."); Assert(data.GetArrayLength() == 2, "Expected entries.list to return seeded markdown entries.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Expected entries.list sort order by file name."); Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Expected entries.list sort order by file name.");
Assert(data[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name."); Assert(data[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryTemplatesCrudExcludesFromEntriesListAsync() static async Task TestEntryTemplatesCrudExcludesFromEntriesListAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-template-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "daily entry");
var entry = NewEntry(); var entry = NewEntry();
await SaveEntryForTestAsync(entry, "2026-02-03", "daily entry");
var saveRequest = JsonSerializer.Serialize(new var saveRequest = JsonSerializer.Serialize(new
{ {
action = "templates.save", action = "templates.save",
payload = new payload = new
{ {
name = "Weekly Review", name = "Weekly Review",
content = "# Weekly Review\n\n## Wins\n- one", content = "# Weekly Review\n\n## Wins\n- one"
dataDirectory = root
} }
}); });
var saveResponse = await entry.HandleCommandAsync(saveRequest); var saveResponse = await entry.HandleCommandAsync(saveRequest);
using var saveDoc = JsonDocument.Parse(saveResponse); using var saveDoc = JsonDocument.Parse(saveResponse);
Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save."); Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save.");
var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(templatePath.EndsWith(".template.md", StringComparison.OrdinalIgnoreCase), "Template file should end with .template.md."); Assert(templatePath.EndsWith("Weekly%20Review.template.md", StringComparison.OrdinalIgnoreCase), "Template path should be canonical db://template path.");
Assert(File.Exists(templatePath), "Template file should exist.");
var listTemplatesRequest = JsonSerializer.Serialize(new var listTemplatesRequest = JsonSerializer.Serialize(new
{ {
action = "templates.list", action = "templates.list",
payload = new payload = new { }
{
dataDirectory = root
}
}); });
var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest); var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest);
using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse); using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse);
@ -235,10 +188,7 @@ hello world
var listEntriesRequest = JsonSerializer.Serialize(new var listEntriesRequest = JsonSerializer.Serialize(new
{ {
action = "entries.list", action = "entries.list",
payload = new payload = new { }
{
dataDirectory = root
}
}); });
var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest); var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest);
using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse); using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse);
@ -273,33 +223,18 @@ hello world
using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse); using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse);
Assert(deleteTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.delete."); Assert(deleteTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.delete.");
Assert(deleteTemplateDoc.RootElement.GetProperty("data").GetBoolean(), "Expected templates.delete to return true."); Assert(deleteTemplateDoc.RootElement.GetProperty("data").GetBoolean(), "Expected templates.delete to return true.");
Assert(!File.Exists(templatePath), "Template file should be deleted.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
} }
static async Task TestEntrySearchEntriesMatchesRawContentAsync() static async Task TestEntrySearchEntriesMatchesRawContentAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "## Summary\nAlpha line\ncommon token");
File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "## Summary\nbeta line\nCOMMON token");
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "## Summary\ngamma only");
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
query = "common token", query = "common token",
} }
}); });
@ -312,32 +247,16 @@ hello world
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content."); Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesWithoutQueryReturnsAllAsync() static async Task TestEntrySearchEntriesWithoutQueryReturnsAllAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "one");
File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "two");
File.WriteAllText(Path.Combine(root, "ignore.txt"), "not markdown");
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new { }
{
dataDirectory = root,
}
}); });
var response = await entry.HandleCommandAsync(request); var response = await entry.HandleCommandAsync(request);
@ -346,30 +265,19 @@ hello world
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query."); Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query.");
var data = doc.RootElement.GetProperty("data"); var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries."); Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 2, "Expected all markdown files to be returned when query is omitted."); Assert(data.GetArrayLength() == 3, "Expected all seeded markdown entries to be returned when query is omitted.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
} }
static async Task TestEntrySearchEntriesDateRangeFilterAsync() static async Task TestEntrySearchEntriesDateRangeFilterAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
startDate = "2026-02-02", startDate = "2026-02-02",
endDate = "2026-02-28", endDate = "2026-02-28",
} }
@ -383,28 +291,17 @@ hello world
Assert(data.GetArrayLength() == 1, "Expected one result for filtered date range."); Assert(data.GetArrayLength() == 1, "Expected one result for filtered date range.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-05.md", "Date-range result mismatch."); Assert(data[0].GetProperty("FileName").GetString() == "2026-02-05.md", "Date-range result mismatch.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesSectionFilterAsync() static async Task TestEntrySearchEntriesSectionFilterAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
query = "focus area", query = "focus area",
section = "Reflection", section = "Reflection",
} }
@ -418,28 +315,17 @@ hello world
Assert(data.GetArrayLength() == 1, "Expected one section-scoped result."); Assert(data.GetArrayLength() == 1, "Expected one section-scoped result.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch."); Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesTagTypeFilterAsync() static async Task TestEntrySearchEntriesTagTypeFilterAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
tags = new[] { "stress" }, tags = new[] { "stress" },
types = new[] { "!TRIGGER" }, types = new[] { "!TRIGGER" },
} }
@ -453,28 +339,17 @@ hello world
Assert(data.GetArrayLength() == 1, "Expected one result for fragment tag/type filters."); Assert(data.GetArrayLength() == 1, "Expected one result for fragment tag/type filters.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Tag/type filter result mismatch."); Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Tag/type filter result mismatch.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesCheckboxFilterAsync() static async Task TestEntrySearchEntriesCheckboxFilterAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
@checked = new[] { "med taken" }, @checked = new[] { "med taken" },
@unchecked = new[] { "drink water" }, @unchecked = new[] { "drink water" },
} }
@ -487,28 +362,17 @@ hello world
var data = doc.RootElement.GetProperty("data"); var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters."); Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesRejectsInvalidDateAsync() static async Task TestEntrySearchEntriesRejectsInvalidDateAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry(); var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "search.entries", action = "search.entries",
payload = new payload = new
{ {
dataDirectory = root,
startDate = "2026/02/01", startDate = "2026/02/01",
} }
}); });
@ -520,28 +384,43 @@ hello world
var error = doc.RootElement.GetProperty("error").GetString() ?? ""; var error = doc.RootElement.GetProperty("error").GetString() ?? "";
Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error."); Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error.");
} }
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static Task TestSidecarSearchCliFilteredAsync() static Task TestSidecarSearchCliFilteredAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data"); var config = NewConfigService(root);
Directory.CreateDirectory(dataDir); var dbService = new JournalDatabaseService(config);
using var session = new DatabaseSessionService(dbService);
session.SetPassword("vault-pass-123");
var repo = new SqliteEntryFileRepository(session);
var entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
try try
{ {
WriteSearchFixtureFiles(dataDir); entryFiles.SaveEntry(new EntrySavePayload("""
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); Date: 2026-02-01
## Summary
common
- [x] med taken
!TRIGGER #stress
matched body
""", Mode: "Overwrite", FileName: "2026-02-01"));
entryFiles.SaveEntry(new EntrySavePayload("""
Date: 2026-02-05
## Summary
common
- [ ] drink water
!NOTE #daily
non match
""", Mode: "Overwrite", FileName: "2026-02-05"));
var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand( var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand(
[ [
"common", "common",
"--data-dir", dataDir,
"--start-date", "2026-02-01", "--start-date", "2026-02-01",
"--end-date", "2026-02-28", "--end-date", "2026-02-28",
"--tag", "stress", "--tag", "stress",
@ -557,6 +436,7 @@ hello world
} }
finally finally
{ {
session.Dispose();
if (Directory.Exists(root)) if (Directory.Exists(root))
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);
} }
@ -567,19 +447,27 @@ hello world
static Task TestSidecarSearchCliEmptyDataAsync() static Task TestSidecarSearchCliEmptyDataAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data"); var config = NewConfigService(root);
Directory.CreateDirectory(dataDir); var dbService = new JournalDatabaseService(config);
using var session = new DatabaseSessionService(dbService);
session.SetPassword("vault-pass-123");
var repo = new SqliteEntryFileRepository(session);
var entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
try try
{ {
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand([]));
var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand(["--data-dir", dataDir]));
Assert(exitCode == 0, "Expected search CLI command to return success for empty data directory."); Assert(exitCode == 0, "Expected search CLI command to return success for empty data store.");
Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message."); Assert(stdout.Contains("No journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message.");
} }
finally finally
{ {
session.Dispose();
if (Directory.Exists(root)) if (Directory.Exists(root))
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);
} }
@ -589,7 +477,6 @@ hello world
static Task TestEntrySavePayloadFileNameDeserializationAsync() static Task TestEntrySavePayloadFileNameDeserializationAsync()
{ {
// Simulate what DeserializePayload does: parse JSON to JsonElement, then Deserialize<T>
var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}"""; var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}""";
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var element = JsonSerializer.Deserialize<JsonElement>(json); var element = JsonSerializer.Deserialize<JsonElement>(json);
@ -607,26 +494,31 @@ hello world
static async Task TestEntrySaveWithFileNameAsync() static async Task TestEntrySaveWithFileNameAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root); var config = NewConfigService(root);
var dbService = new JournalDatabaseService(config);
using var session = new DatabaseSessionService(dbService);
session.SetPassword("vault-pass-123");
var repo = new SqliteEntryFileRepository(session);
var service = new EntryFileService(repo);
try try
{ {
// Use EntryFileService directly to test the full save path with fileName
var service = new EntryFileService(new DiskEntryFileRepository());
var payload = new EntrySavePayload( var payload = new EntrySavePayload(
Content: "# Custom Entry\n\nHello world", Content: "# Custom Entry\n\nHello world",
FilePath: null, FilePath: null,
Mode: "Overwrite", Mode: "Overwrite",
FileName: "My Custom Name"); FileName: "My Custom Name");
var result = service.SaveEntry(payload, root); var result = service.SaveEntry(payload);
var expectedPath = Path.GetFullPath(Path.Combine(root, "My Custom Name.md")); Assert(result.FilePath.StartsWith("db://entry/", StringComparison.OrdinalIgnoreCase), "Expected canonical db://entry path.");
Assert(result.FilePath == expectedPath, $"Expected path '{expectedPath}' but got '{result.FilePath}'."); Assert(result.FilePath.Contains("My%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase), "Expected escaped custom name in db path.");
Assert(File.Exists(expectedPath), "Custom-named file should exist on disk.");
Assert(File.ReadAllText(expectedPath).Contains("Hello world"), "File content should match."); var loaded = service.LoadEntry(result.FilePath);
Assert(loaded.Entry.RawContent.Contains("Hello world", StringComparison.Ordinal), "Stored content should match.");
// Also test via Entry.HandleCommandAsync with JSON to verify full deserialization chain
var entry = NewEntry(); var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
@ -644,14 +536,87 @@ hello world
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save with fileName."); Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save with fileName.");
var savedFilePath = doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? ""; var savedFilePath = doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(savedFilePath.Contains("Another Custom Name.md", StringComparison.OrdinalIgnoreCase), Assert(savedFilePath.Contains("Another%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase),
$"Expected file path to contain 'Another Custom Name.md' but got '{savedFilePath}'."); $"Expected file path to contain 'Another%20Custom%20Name.md' but got '{savedFilePath}'.");
} }
finally finally
{ {
session.Dispose();
if (Directory.Exists(root)) if (Directory.Exists(root))
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);
} }
} }
}
private static async Task<string> SaveEntryForTestAsync(Entry entry, string fileStem, string content)
{
var request = JsonSerializer.Serialize(new
{
action = "entries.save",
payload = new
{
content,
mode = "Overwrite",
fileName = fileStem
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for test seed entries.save.");
return doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
}
private static async Task<string> LoadEntryForTestAsync(Entry entry, string filePath)
{
var request = JsonSerializer.Serialize(new
{
action = "entries.load",
payload = new
{
filePath
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for test entries.load.");
return doc.RootElement.GetProperty("data").GetProperty("Entry").GetProperty("RawContent").GetString() ?? "";
}
private static async Task SeedSearchFixtureEntriesAsync(Entry entry)
{
await SaveEntryForTestAsync(entry, "2026-02-01", """
Date: 2026-02-01
## Summary
Alpha line
common token
## Reflection
focus area
- [x] med taken
!TRIGGER #stress
fragment one
""");
await SaveEntryForTestAsync(entry, "2026-02-05", """
Date: 2026-02-05
## Summary
beta line
COMMON token
## Reflection
other notes
- [ ] drink water
!NOTE #daily
fragment two
""");
await SaveEntryForTestAsync(entry, "2026-03-01", """
Date: 2026-03-01
## Summary
gamma only
## Reflection
nothing related
!NOTE #other
fragment three
""");
}
}

View File

@ -53,20 +53,19 @@ internal static partial class Program
try try
{ {
// Set up encrypted DB session var configService = NewConfigService(tempRoot);
var configService = new JournalConfigService();
var dbService = new JournalDatabaseService(configService); var dbService = new JournalDatabaseService(configService);
// First session: create a fragment // First session: create a fragment
using var session1 = new DatabaseSessionService(dbService); using var session1 = new DatabaseSessionService(dbService);
session1.SetPassword(password, dataDir); session1.SetPassword(password);
var repo1 = new SqliteFragmentRepository(session1); var repo1 = new SqliteFragmentRepository(session1);
var service1 = new FragmentService(repo1); var service1 = new FragmentService(repo1);
var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"])); var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
// Second session: verify persistence // Second session: verify persistence
using var session2 = new DatabaseSessionService(dbService); using var session2 = new DatabaseSessionService(dbService);
session2.SetPassword(password, dataDir); session2.SetPassword(password);
var repo2 = new SqliteFragmentRepository(session2); var repo2 = new SqliteFragmentRepository(session2);
var service2 = new FragmentService(repo2); var service2 = new FragmentService(repo2);
var loaded = service2.GetById(created.Id); var loaded = service2.GetById(created.Id);

View File

@ -6,26 +6,64 @@ internal static partial class Program
return new FragmentService(repo); return new FragmentService(repo);
} }
static Entry NewEntry() static Entry NewEntry(bool unlocked = true, string password = "vault-pass-123", string? root = null)
{ {
var dbService = new JournalDatabaseService(new JournalConfigService()); var config = NewConfigService(root);
var dbService = new JournalDatabaseService(config);
var session = new DatabaseSessionService(dbService); var session = new DatabaseSessionService(dbService);
if (unlocked)
session.SetPassword(password);
var entryRepo = new SqliteEntryFileRepository(session);
return new Entry( return new Entry(
NewService(), NewService(),
new EntrySearchService(), new EntrySearchService(entryRepo),
new VaultStorageService(new VaultCryptoService()), new VaultStorageService(new VaultCryptoService(), dbService),
dbService, dbService,
session, session,
new JournalConfigService(), config,
new DisabledAiService("none"), new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"), new DisabledSpeechBridgeService("none"),
new EntryFileService(new DiskEntryFileRepository()), new EntryFileService(entryRepo),
new ListService(new SqliteListRepository(session)), new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)), new TodoService(new SqliteTodoRepository(session)),
new CommandLogger()); new CommandLogger());
} }
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); static Entry NewLockedEntry() => NewEntry(unlocked: false);
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(NewConfigService());
static IJournalConfigService NewConfigService(string? root = null, string? databaseFilename = null)
{
var baseConfig = new JournalConfigService().Current;
var normalizedRoot = Path.GetFullPath(root ?? Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")));
var appDirectory = Path.Combine(normalizedRoot, "journal");
var vaultDirectory = Path.Combine(appDirectory, "vault");
var logDirectory = Path.Combine(normalizedRoot, "logs");
Directory.CreateDirectory(vaultDirectory);
Directory.CreateDirectory(logDirectory);
var config = baseConfig with
{
ProjectRoot = normalizedRoot,
AppDirectory = appDirectory,
VaultDirectory = vaultDirectory,
LogDirectory = logDirectory,
PidFile = Path.Combine(logDirectory, "nicegui_server.pid"),
ServerControlFile = Path.Combine(logDirectory, "server_control.action"),
DatabaseFilename = databaseFilename ?? "journal_cache.db"
};
return new FixedConfigService(config);
}
private sealed class FixedConfigService(JournalConfig config) : IJournalConfigService
{
public JournalConfig Current => config;
}
static Dictionary<string, string> ReadVaultEntryTexts(string vaultPath, string password) static Dictionary<string, string> ReadVaultEntryTexts(string vaultPath, string password)
{ {

View File

@ -41,40 +41,53 @@ internal static partial class Program
static Task TestVaultMonthlyFilenameParityAsync() static Task TestVaultMonthlyFilenameParityAsync()
{ {
IVaultStorageService vaultStorage = new VaultStorageService(new VaultCryptoService()); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var name = vaultStorage.GetMonthlyVaultFileName(new DateTime(2026, 2, 7)); var config = NewConfigService(root);
Assert(name == "2026-02.vault", "Monthly vault filename must match yyyy-MM.vault format."); var dbService = new JournalDatabaseService(config);
var storage = new VaultStorageService(new VaultCryptoService(), dbService);
try
{
var dbPath = dbService.GetDatabasePath();
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
File.WriteAllText(dbPath, "db-bytes");
storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
var expectedName = $"_db_{Path.GetFileName(dbPath)}.vault";
Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, expectedName)), "Expected DB vault snapshot filename with _db_ prefix and .vault suffix.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask; return Task.CompletedTask;
} }
static Task TestVaultLoadClearsAndExtractsAsync() static Task TestVaultLoadClearsAndExtractsAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
File.WriteAllText(Path.Combine(dataDir, "old_file.md"), "stale"); var dbPath = dbService.GetDatabasePath();
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var originalBytes = Guid.NewGuid().ToByteArray();
File.WriteAllBytes(dbPath, originalBytes);
var zipBytes = CreateZipBytes(new Dictionary<string, string> storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
{
["2026-02-01.md"] = "hello from vault"
});
var crypto = new VaultCryptoService();
var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123");
File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted);
IVaultStorageService storage = new VaultStorageService(crypto); File.WriteAllBytes(dbPath, [1, 2, 3, 4]);
var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
Assert(ok, "Expected vault load success with correct password."); Assert(ok, "Expected vault load success with correct password.");
Assert(!File.Exists(Path.Combine(dataDir, "old_file.md")), "Data directory should be cleared before extraction."); var loaded = File.ReadAllBytes(dbPath);
var extractedPath = Path.Combine(dataDir, "2026-02-01.md"); Assert(loaded.SequenceEqual(originalBytes), "Expected DB file bytes restored from vault snapshot.");
Assert(File.Exists(extractedPath), "Expected markdown file extracted from vault archive.");
Assert(File.ReadAllText(extractedPath) == "hello from vault", "Extracted file content mismatch.");
} }
finally finally
{ {
@ -88,25 +101,21 @@ internal static partial class Program
static Task TestVaultLoadWrongPasswordPreservesVaultAsync() static Task TestVaultLoadWrongPasswordPreservesVaultAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
var zipBytes = CreateZipBytes(new Dictionary<string, string> var dbPath = dbService.GetDatabasePath();
{ Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
["2026-02-01.md"] = "hello from vault" File.WriteAllText(dbPath, "db payload");
});
var crypto = new VaultCryptoService(); storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123"); var vaultPath = Path.Combine(config.Current.VaultDirectory, $"_db_{Path.GetFileName(dbPath)}.vault");
var vaultPath = Path.Combine(vaultDir, "2026-02.vault");
File.WriteAllBytes(vaultPath, encrypted);
var before = File.ReadAllBytes(vaultPath); var before = File.ReadAllBytes(vaultPath);
IVaultStorageService storage = new VaultStorageService(crypto); var ok = storage.LoadAllVaults("wrong-password", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
var ok = storage.LoadAllVaults("wrong-password", vaultDir, dataDir);
var after = File.ReadAllBytes(vaultPath); var after = File.ReadAllBytes(vaultPath);
Assert(!ok, "Expected vault load failure with wrong password."); Assert(!ok, "Expected vault load failure with wrong password.");
@ -124,22 +133,19 @@ internal static partial class Program
static Task TestVaultLoadLegacyInitVaultHandlingAsync() static Task TestVaultLoadLegacyInitVaultHandlingAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
var legacyPath = Path.Combine(vaultDir, "_init_vault.vault"); var legacyPath = Path.Combine(config.Current.VaultDirectory, "_init_vault.vault");
File.WriteAllBytes(legacyPath, [1, 2, 3, 4]); File.WriteAllBytes(legacyPath, [1, 2, 3, 4]);
IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbService.GetDatabasePath())!);
var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir);
Assert(ok, "Legacy-only vault directory should still be treated as successful load state."); Assert(ok, "Legacy-only vault directory should still be treated as successful load state.");
Assert(!File.Exists(legacyPath), "Legacy _init_vault.vault should be removed during load."); Assert(File.Exists(legacyPath), "Legacy _init_vault.vault should be ignored in SQLCipher snapshot mode.");
Assert(Directory.Exists(dataDir), "Data directory should exist after load workflow.");
} }
finally finally
{ {
@ -153,43 +159,25 @@ internal static partial class Program
static Task TestVaultCurrentMonthSaveOptimizedAsync() static Task TestVaultCurrentMonthSaveOptimizedAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "feb one"); var dbPath = dbService.GetDatabasePath();
File.WriteAllText(Path.Combine(dataDir, "2026-02-18.md"), "feb two"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
File.WriteAllText(Path.Combine(dataDir, "2026-01-31.md"), "jan one"); File.WriteAllText(dbPath, "initial db");
IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
var now = new DateTime(2026, 2, 22, 12, 0, 0, DateTimeKind.Utc); var vaultPath = Path.Combine(config.Current.VaultDirectory, $"_db_{Path.GetFileName(dbPath)}.vault");
Assert(File.Exists(vaultPath), "Expected DB vault snapshot file to be created.");
var firstSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); var firstLength = new FileInfo(vaultPath).Length;
Assert(firstSaved, "Expected first current-month save to write vault data."); storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, Path.GetDirectoryName(dbPath)!);
var secondLength = new FileInfo(vaultPath).Length;
var febVaultPath = Path.Combine(vaultDir, "2026-02.vault"); Assert(firstLength > 0 && secondLength > 0, "Vault snapshot should remain non-empty across repeated rebuilds.");
var janVaultPath = Path.Combine(vaultDir, "2026-01.vault");
Assert(File.Exists(febVaultPath), "Expected current-month vault file to be created.");
Assert(!File.Exists(janVaultPath), "Current-month save should not write non-current month vault files.");
var entries = ReadVaultEntryTexts(febVaultPath, "vault-pass-123");
Assert(entries.Count == 2, "Current-month vault should include only current-month markdown files.");
Assert(entries.ContainsKey("2026-02-01.md"), "Missing first current-month entry in vault archive.");
Assert(entries.ContainsKey("2026-02-18.md"), "Missing second current-month entry in vault archive.");
Assert(!entries.ContainsKey("2026-01-31.md"), "Current-month vault must not include previous-month files.");
var beforeSkipBytes = File.ReadAllBytes(febVaultPath);
var secondSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now);
var afterSkipBytes = File.ReadAllBytes(febVaultPath);
Assert(!secondSaved, "Expected unchanged current-month save to skip write.");
Assert(beforeSkipBytes.SequenceEqual(afterSkipBytes), "Vault bytes should remain unchanged when save is skipped.");
File.WriteAllText(Path.Combine(dataDir, "2026-02-18.md"), "feb two changed");
var thirdSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now);
Assert(thirdSaved, "Expected save to run after current-month file change.");
} }
finally finally
{ {
@ -203,30 +191,21 @@ internal static partial class Program
static Task TestVaultRebuildAllVaultsAsync() static Task TestVaultRebuildAllVaultsAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
File.WriteAllText(Path.Combine(dataDir, "2026-01-31.md"), "jan body"); var dbDir = Path.GetDirectoryName(dbService.GetDatabasePath())!;
File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "feb body"); Directory.CreateDirectory(dbDir);
File.WriteAllText(Path.Combine(dataDir, "not-a-journal.md"), "should be ignored"); File.WriteAllText(Path.Combine(dbDir, "journal_cache.db"), "primary");
File.WriteAllText(Path.Combine(dbDir, "analytics.db"), "secondary");
IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir);
storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir);
var janVaultPath = Path.Combine(vaultDir, "2026-01.vault"); Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_journal_cache.db.vault")), "Expected primary DB snapshot vault.");
var febVaultPath = Path.Combine(vaultDir, "2026-02.vault"); Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_analytics.db.vault")), "Expected secondary DB snapshot vault.");
Assert(File.Exists(janVaultPath), "Expected January vault from rebuild flow.");
Assert(File.Exists(febVaultPath), "Expected February vault from rebuild flow.");
var janEntries = ReadVaultEntryTexts(janVaultPath, "vault-pass-123");
var febEntries = ReadVaultEntryTexts(febVaultPath, "vault-pass-123");
Assert(janEntries.Count == 1 && janEntries.ContainsKey("2026-01-31.md"), "January vault contents mismatch.");
Assert(febEntries.Count == 1 && febEntries.ContainsKey("2026-02-01.md"), "February vault contents mismatch.");
} }
finally finally
{ {
@ -240,21 +219,22 @@ internal static partial class Program
static Task TestVaultClearDataDirectoryAsync() static Task TestVaultClearDataDirectoryAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data"); var config = NewConfigService(root);
Directory.CreateDirectory(dataDir); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(Path.Combine(dataDir, "nested")); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
var scratchDir = Path.Combine(root, "scratch-data");
Directory.CreateDirectory(scratchDir);
Directory.CreateDirectory(Path.Combine(scratchDir, "nested"));
try try
{ {
File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "decrypted content"); File.WriteAllText(Path.Combine(scratchDir, "tmp.md"), "decrypted content");
File.WriteAllText(Path.Combine(dataDir, "journal_cache.db"), "cache"); File.WriteAllText(Path.Combine(scratchDir, "nested", "tmp.txt"), "temp");
File.WriteAllText(Path.Combine(dataDir, "nested", "tmp.txt"), "temp");
IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); storage.ClearDataDirectory(scratchDir);
storage.ClearDataDirectory(dataDir);
Assert(Directory.Exists(dataDir), "Data directory should be recreated after cleanup."); Assert(!Directory.Exists(scratchDir), "Non-db scratch directory should be deleted by clear_data_directory.");
Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after cleanup.");
} }
finally finally
{ {
@ -268,30 +248,28 @@ internal static partial class Program
static async Task TestEntryVaultLoadAllEmptyAsync() static async Task TestEntryVaultLoadAllEmptyAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); Directory.CreateDirectory(root);
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(vaultDir);
try try
{ {
var entry = NewEntry(); var entry = NewLockedEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "vault.load_all", action = "vault.load_all",
payload = new payload = new
{ {
password = "vault-pass-123", password = "vault-pass-123",
vaultDirectory = vaultDir, vaultDirectory = Path.Combine(root, "vault")
dataDirectory = dataDir,
} }
}); });
Directory.CreateDirectory(Path.Combine(root, "vault"));
var response = await entry.HandleCommandAsync(request); var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response); using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for empty vault directory load."); Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for empty vault directory load.");
Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault directory."); Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault directory.");
Assert(Directory.Exists(dataDir), "Expected data directory to be created by load workflow.");
} }
finally finally
{ {
@ -301,22 +279,12 @@ internal static partial class Program
} }
static async Task TestEntryVaultClearDataDirectoryAsync() static async Task TestEntryVaultClearDataDirectoryAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
File.WriteAllText(Path.Combine(dataDir, "tmp.md"), "x");
try
{ {
var entry = NewEntry(); var entry = NewEntry();
var request = JsonSerializer.Serialize(new var request = JsonSerializer.Serialize(new
{ {
action = "vault.clear_data_directory", action = "vault.clear_data_directory",
payload = new payload = new { }
{
dataDirectory = dataDir,
}
}); });
var response = await entry.HandleCommandAsync(request); var response = await entry.HandleCommandAsync(request);
@ -324,30 +292,32 @@ internal static partial class Program
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for clear_data_directory."); Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for clear_data_directory.");
Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected clear_data_directory result=true."); Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected clear_data_directory result=true.");
Assert(Directory.Exists(dataDir), "Expected data directory to exist after clear.");
Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Expected data directory to be empty after clear.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
} }
static Task TestSidecarVaultCliLoadAsync() static Task TestSidecarVaultCliLoadAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
using var session = new DatabaseSessionService(dbService);
session.SetPassword("vault-pass-123");
var repo = new SqliteEntryFileRepository(session);
var entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
var vaultDir = Path.Combine(root, "vault-cli");
Directory.CreateDirectory(vaultDir); Directory.CreateDirectory(vaultDir);
try try
{ {
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); var exitCode = cli.RunVaultCommand(["load", "--password", "vault-pass-123", "--vault-dir", vaultDir]);
var exitCode = cli.RunVaultCommand(["load", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]);
Assert(exitCode == 0, "Expected vault load CLI command to succeed on empty vault directory."); Assert(exitCode == 0, "Expected vault load CLI command to succeed on empty vault directory.");
Assert(Directory.Exists(dataDir), "Expected data directory to be created by vault load CLI command.");
var dbDir = Path.Combine(config.Current.VaultDirectory, "db");
Assert(Directory.Exists(dbDir), "Expected db directory to be created by vault load CLI command.");
} }
finally finally
{ {
@ -361,20 +331,29 @@ internal static partial class Program
static Task TestSidecarVaultCliSaveAsync() static Task TestSidecarVaultCliSaveAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
using var session = new DatabaseSessionService(dbService);
session.SetPassword("vault-pass-123");
var repo = new SqliteEntryFileRepository(session);
var entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
var vaultDir = Path.Combine(root, "vault-cli");
Directory.CreateDirectory(vaultDir); Directory.CreateDirectory(vaultDir);
Directory.CreateDirectory(dataDir);
try try
{ {
File.WriteAllText(Path.Combine(dataDir, "2026-02-22.md"), "entry body"); entryFiles.SaveEntry(new EntrySavePayload("entry body", Mode: "Overwrite", FileName: "2026-02-22"));
session.CloseConnection();
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); var exitCode = cli.RunVaultCommand(["save", "--password", "vault-pass-123", "--vault-dir", vaultDir]);
var exitCode = cli.RunVaultCommand(["save", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]);
Assert(exitCode == 0, "Expected vault save CLI command to succeed."); Assert(exitCode == 0, "Expected vault save CLI command to succeed.");
Assert(File.Exists(Path.Combine(vaultDir, "2026-02.vault")), "Expected monthly vault file to be written by save CLI command."); Assert(File.Exists(Path.Combine(vaultDir, "_db_journal_cache.db.vault")), "Expected DB vault snapshot file to be written by save CLI command.");
} }
finally finally
{ {
@ -388,41 +367,46 @@ internal static partial class Program
static Task TestVaultCustomEntryRoundtripAsync() static Task TestVaultCustomEntryRoundtripAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var storage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir);
try try
{ {
// Create both date-named and custom-named entries using (var seedSession = new DatabaseSessionService(dbService))
File.WriteAllText(Path.Combine(dataDir, "2026-02-01.md"), "date entry"); {
File.WriteAllText(Path.Combine(dataDir, "My Custom Entry.md"), "custom entry body"); seedSession.SetPassword("vault-pass-123");
File.WriteAllText(Path.Combine(dataDir, "Work Notes.md"), "work notes body"); var seedRepo = new SqliteEntryFileRepository(seedSession);
var seedEntryFiles = new EntryFileService(seedRepo);
// Rebuild vaults (simulates app close) seedEntryFiles.SaveEntry(new EntrySavePayload("date entry", Mode: "Overwrite", FileName: "2026-02-01"));
IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); seedEntryFiles.SaveEntry(new EntrySavePayload("custom entry body", Mode: "Overwrite", FileName: "My Custom Entry"));
storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); seedEntryFiles.SaveEntry(new EntrySavePayload("work notes body", Mode: "Overwrite", FileName: "Work Notes"));
seedSession.CloseConnection();
}
// Verify custom vault was created var dbPath = dbService.GetDatabasePath();
var customVaultPath = Path.Combine(vaultDir, "_custom_entries.vault"); var dbDir = Path.GetDirectoryName(dbPath)!;
Assert(File.Exists(customVaultPath), "Expected _custom_entries.vault to be created.");
Assert(File.Exists(Path.Combine(vaultDir, "2026-02.vault")), "Expected monthly vault for date entry.");
// Clear data directory (simulates app close step 2) storage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir);
storage.ClearDataDirectory(dataDir); Assert(File.Exists(Path.Combine(config.Current.VaultDirectory, "_db_journal_cache.db.vault")), "Expected DB vault snapshot to be created.");
Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after clear.");
// Load vaults (simulates app restart) File.Delete(dbPath);
var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); Assert(!File.Exists(dbPath), "DB file should be deleted before restore.");
var ok = storage.LoadAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir);
Assert(ok, "Expected vault load to succeed."); Assert(ok, "Expected vault load to succeed.");
// Verify all entries are restored using (var verifySession = new DatabaseSessionService(dbService))
Assert(File.Exists(Path.Combine(dataDir, "2026-02-01.md")), "Date entry should be restored from vault."); {
Assert(File.Exists(Path.Combine(dataDir, "My Custom Entry.md")), "Custom entry should be restored from vault."); verifySession.SetPassword("vault-pass-123");
Assert(File.Exists(Path.Combine(dataDir, "Work Notes.md")), "Second custom entry should be restored from vault."); var verifyRepo = new SqliteEntryFileRepository(verifySession);
Assert(File.ReadAllText(Path.Combine(dataDir, "My Custom Entry.md")) == "custom entry body", "Custom entry content mismatch."); var verifyEntryFiles = new EntryFileService(verifyRepo);
Assert(File.ReadAllText(Path.Combine(dataDir, "Work Notes.md")) == "work notes body", "Second custom entry content mismatch."); var allEntries = verifyEntryFiles.ListEntries();
Assert(allEntries.Any(e => e.FileName == "2026-02-01.md"), "Date entry should be restored from vault DB snapshot.");
Assert(allEntries.Any(e => e.FileName == "My Custom Entry.md"), "Custom entry should be restored from vault DB snapshot.");
Assert(allEntries.Any(e => e.FileName == "Work Notes.md"), "Second custom entry should be restored from vault DB snapshot.");
}
} }
finally finally
{ {
@ -436,22 +420,34 @@ internal static partial class Program
static async Task TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync() static async Task TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault"); var config = NewConfigService(root);
var dataDir = Path.Combine(root, "data"); var dbService = new JournalDatabaseService(config);
Directory.CreateDirectory(vaultDir); var vaultStorage = new VaultStorageService(new VaultCryptoService(), dbService);
Directory.CreateDirectory(dataDir); var session = new DatabaseSessionService(dbService);
var repo = new SqliteEntryFileRepository(session);
var entryFiles = new EntryFileService(repo);
var entrySearch = new EntrySearchService(repo);
var entry = new Entry(
NewService(),
entrySearch,
vaultStorage,
dbService,
session,
config,
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
entryFiles,
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
new CommandLogger());
try try
{ {
var zipBytes = CreateZipBytes(new Dictionary<string, string> var dbDir = Path.GetDirectoryName(dbService.GetDatabasePath())!;
{ Directory.CreateDirectory(dbDir);
["2026-02-01.md"] = "hello from vault" File.WriteAllBytes(dbService.GetDatabasePath(), Guid.NewGuid().ToByteArray());
}); vaultStorage.RebuildAllVaults("vault-pass-123", config.Current.VaultDirectory, dbDir);
var crypto = new VaultCryptoService();
var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123");
File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted);
var entry = NewEntry();
var loadRequest = JsonSerializer.Serialize(new var loadRequest = JsonSerializer.Serialize(new
{ {
@ -459,8 +455,7 @@ internal static partial class Program
payload = new payload = new
{ {
password = "wrong-password", password = "wrong-password",
vaultDirectory = vaultDir, vaultDirectory = config.Current.VaultDirectory
dataDirectory = dataDir
} }
}); });
var loadResponse = await entry.HandleCommandAsync(loadRequest); var loadResponse = await entry.HandleCommandAsync(loadRequest);
@ -488,39 +483,13 @@ internal static partial class Program
static async Task TestEntryTemplateSaveAutoSyncsVaultAsync() static async Task TestEntryTemplateSaveAutoSyncsVaultAsync()
{ {
var root = Path.Combine(Path.GetTempPath(), "journal-entry-vault-sync-smoke", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "journal-entry-vault-sync-smoke", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "project"); var entry = NewEntry(root: root);
var appDirectory = Path.Combine(projectRoot, "journal");
var vaultDir = Path.Combine(appDirectory, "vault");
var dataDir = Path.Combine(appDirectory, "data");
Directory.CreateDirectory(vaultDir);
Directory.CreateDirectory(dataDir);
var previousProjectRoot = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); var configResponse = await entry.HandleCommandAsync("""{"action":"config.get"}""");
var previousDataDir = Environment.GetEnvironmentVariable("JOURNAL_DATA_DIR"); using var configDoc = JsonDocument.Parse(configResponse);
var previousVaultDir = Environment.GetEnvironmentVariable("JOURNAL_VAULT_DIR"); var configData = configDoc.RootElement.GetProperty("data");
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", projectRoot); var vaultDir = configData.GetProperty("VaultDirectory").GetString() ?? "";
Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", dataDir); var dbFilename = configData.GetProperty("DatabaseFilename").GetString() ?? "journal_cache.db";
Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", vaultDir);
try
{
var entry = NewEntry();
var password = "vault-pass-123";
var unlockRequest = JsonSerializer.Serialize(new
{
action = "vault.load_all",
payload = new
{
password,
vaultDirectory = vaultDir,
dataDirectory = dataDir
}
});
var unlockResponse = await entry.HandleCommandAsync(unlockRequest);
using var unlockDoc = JsonDocument.Parse(unlockResponse);
Assert(unlockDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.load_all envelope to succeed.");
Assert(unlockDoc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault.");
var saveTemplateRequest = JsonSerializer.Serialize(new var saveTemplateRequest = JsonSerializer.Serialize(new
{ {
@ -528,49 +497,17 @@ internal static partial class Program
payload = new payload = new
{ {
name = "Weekly Review", name = "Weekly Review",
content = "## Wins\n- shipped feature", content = "## Wins\n- shipped feature"
dataDirectory = dataDir
} }
}); });
var saveTemplateResponse = await entry.HandleCommandAsync(saveTemplateRequest); var saveTemplateResponse = await entry.HandleCommandAsync(saveTemplateRequest);
using var saveTemplateDoc = JsonDocument.Parse(saveTemplateResponse); using var saveTemplateDoc = JsonDocument.Parse(saveTemplateResponse);
Assert(saveTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected templates.save to succeed."); Assert(saveTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected templates.save to succeed.");
var customVaultPath = Path.Combine(vaultDir, "_custom_entries.vault"); var dbVaultPath = Path.Combine(vaultDir, $"_db_{dbFilename}.vault");
Assert(File.Exists(customVaultPath), "Expected template save to auto-sync custom entries vault."); Assert(File.Exists(dbVaultPath), "Expected template save auto-sync to write DB vault snapshot.");
var entries = ReadVaultEntryTexts(customVaultPath, password);
Assert(entries.ContainsKey("Weekly Review.template.md"), "Expected template file in custom vault archive.");
var clearRequest = JsonSerializer.Serialize(new
{
action = "vault.clear_data_directory",
payload = new
{
dataDirectory = dataDir
}
});
var clearResponse = await entry.HandleCommandAsync(clearRequest);
using var clearDoc = JsonDocument.Parse(clearResponse);
Assert(clearDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected vault.clear_data_directory to succeed.");
var reloadResponse = await entry.HandleCommandAsync(unlockRequest);
using var reloadDoc = JsonDocument.Parse(reloadResponse);
Assert(reloadDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected second vault.load_all envelope to succeed.");
Assert(reloadDoc.RootElement.GetProperty("data").GetBoolean(), "Expected second vault.load_all data=true.");
var restoredTemplatePath = Path.Combine(dataDir, "Weekly Review.template.md");
Assert(File.Exists(restoredTemplatePath), "Expected template to be restored from vault after reload.");
}
finally
{
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", previousProjectRoot);
Environment.SetEnvironmentVariable("JOURNAL_DATA_DIR", previousDataDir);
Environment.SetEnvironmentVariable("JOURNAL_VAULT_DIR", previousVaultDir);
if (Directory.Exists(root)) if (Directory.Exists(root))
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);
} }
}
} }

View File

@ -17,11 +17,11 @@ internal static partial class Program
("Vault crypto decrypts Python payload fixture", TestVaultCryptoDecryptsPythonFixtureAsync), ("Vault crypto decrypts Python payload fixture", TestVaultCryptoDecryptsPythonFixtureAsync),
("Vault key derivation matches Python fixture", TestVaultKeyDerivationMatchesPythonAsync), ("Vault key derivation matches Python fixture", TestVaultKeyDerivationMatchesPythonAsync),
("Vault monthly filename matches parity format", TestVaultMonthlyFilenameParityAsync), ("Vault monthly filename matches parity format", TestVaultMonthlyFilenameParityAsync),
("Vault load clears workspace and extracts decrypted files", TestVaultLoadClearsAndExtractsAsync), ("Vault load restores encrypted DB snapshot", TestVaultLoadClearsAndExtractsAsync),
("Vault load wrong password does not modify vault files", TestVaultLoadWrongPasswordPreservesVaultAsync), ("Vault load wrong password does not modify vault files", TestVaultLoadWrongPasswordPreservesVaultAsync),
("Vault load ignores and removes legacy _init_vault.vault", TestVaultLoadLegacyInitVaultHandlingAsync), ("Vault load ignores legacy _init_vault.vault in snapshot mode", TestVaultLoadLegacyInitVaultHandlingAsync),
("Vault current-month save writes only current month and skips unchanged state", TestVaultCurrentMonthSaveOptimizedAsync), ("Vault rebuild remains repeatable across runs", TestVaultCurrentMonthSaveOptimizedAsync),
("Vault rebuild saves grouped monthly archives from decrypted files", TestVaultRebuildAllVaultsAsync), ("Vault rebuild writes encrypted DB snapshot vault files", TestVaultRebuildAllVaultsAsync),
("Vault clear data directory removes decrypted workspace artifacts", TestVaultClearDataDirectoryAsync), ("Vault clear data directory removes decrypted workspace artifacts", TestVaultClearDataDirectoryAsync),
("Parser extracts date from **Date:** marker", TestParserExtractsBoldDateAsync), ("Parser extracts date from **Date:** marker", TestParserExtractsBoldDateAsync),
("Parser extracts date from Date: marker", TestParserExtractsPlainDateAsync), ("Parser extracts date from Date: marker", TestParserExtractsPlainDateAsync),
@ -50,7 +50,7 @@ internal static partial class Program
("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync), ("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync),
("Database schema parity tables are created", TestDatabaseSchemaParityAsync), ("Database schema parity tables are created", TestDatabaseSchemaParityAsync),
("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync), ("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync),
("Entry db.initialize_schema creates schema in data directory", TestEntryDatabaseInitializeSchemaAsync), ("Entry db.initialize_schema initializes SQLCipher schema", TestEntryDatabaseInitializeSchemaAsync),
("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync), ("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync),
("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync), ("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync),
("Entry config.get returns config payload", TestEntryConfigGetAsync), ("Entry config.get returns config payload", TestEntryConfigGetAsync),
@ -70,10 +70,10 @@ internal static partial class Program
("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync), ("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync),
("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync), ("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync),
("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync), ("Entry vault.load_all wrong password keeps database session locked", TestEntryVaultLoadWrongPasswordKeepsSessionLockedAsync),
("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync), ("Entry vault.clear_data_directory compatibility command succeeds", TestEntryVaultClearDataDirectoryAsync),
("Entry templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync), ("Entry templates.save auto-syncs vault and survives reload", TestEntryTemplateSaveAutoSyncsVaultAsync),
("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync), ("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync),
("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync), ("Sidecar vault CLI save writes DB snapshot vault with --password", TestSidecarVaultCliSaveAsync),
("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync), ("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync),
("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync), ("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync),
("Transport fixtures produce stable envelopes", TestTransportFixturesAsync), ("Transport fixtures produce stable envelopes", TestTransportFixturesAsync),