Compare commits

..

No commits in common. "aa197230a690cdc87dfae1e7b214064eb2b90803" and "941cafba390207c4d21efdee90fb73df824f8cd7" have entirely different histories.

7 changed files with 819 additions and 699 deletions

View File

@ -1,5 +1,6 @@
global using System.ComponentModel.DataAnnotations;
global using System.IO.Compression;
global using System.Security.Cryptography;
global using System.Text.Json;
global using Journal.Core;
global using Journal.Core.Dtos;

View File

@ -11,108 +11,165 @@ internal static partial class Program
static Task TestDatabaseSchemaParityAsync()
{
var service = NewDatabaseService();
var statements = service.GetSchemaStatements();
var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase);
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Assert(tableNames.Contains("entries"), "Schema should contain entries table.");
Assert(tableNames.Contains("sections"), "Schema should contain sections table.");
Assert(tableNames.Contains("fragments"), "Schema should contain fragments table.");
Assert(tableNames.Contains("tags"), "Schema should contain tags table.");
Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table.");
Assert(tableNames.Contains("entry_documents"), "Schema should contain entry_documents table.");
try
{
var service = NewDatabaseService();
var schemaPath = service.WriteSchemaBootstrap(root);
var statements = service.GetSchemaStatements();
var tableNames = new HashSet<string>(statements.Keys, StringComparer.OrdinalIgnoreCase);
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("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(tableNames.Contains("entries"), "Schema should contain entries table.");
Assert(tableNames.Contains("sections"), "Schema should contain sections table.");
Assert(tableNames.Contains("fragments"), "Schema should contain fragments table.");
Assert(tableNames.Contains("tags"), "Schema should contain tags table.");
Assert(tableNames.Contains("fragment_tags"), "Schema should contain fragment_tags table.");
Assert(File.Exists(schemaPath), "Schema bootstrap file should be written.");
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("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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask;
}
static async Task TestEntryDatabaseStatusAsync()
{
var entry = NewEntry(unlocked: false);
var request = JsonSerializer.Serialize(new
{
action = "db.status",
payload = new
{
password = "vault-pass-123"
}
});
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.status.");
var data = doc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in db.status payload.");
Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath.");
Assert(data.TryGetProperty("KeyDerivation", out var keyDerivation), "Expected KeyDerivation in db.status payload.");
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(schemaTables.ValueKind == JsonValueKind.Array && schemaTables.GetArrayLength() >= 5, "Expected schema table list 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.");
try
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "db.status",
payload = new
{
password = "vault-pass-123",
dataDirectory = root
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.status.");
var data = doc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in db.status payload.");
Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath.");
Assert(data.TryGetProperty("KeyDerivation", out var keyDerivation), "Expected KeyDerivation in db.status payload.");
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(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(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()
{
var entry = NewEntry(unlocked: false);
var request = JsonSerializer.Serialize(new
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
action = "db.initialize_schema",
payload = new
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
password = "vault-pass-123"
}
});
action = "db.initialize_schema",
payload = new
{
password = "vault-pass-123",
dataDirectory = root
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema.");
var data = doc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("initialized", out var initialized), "Expected initialized flag in db.initialize_schema response.");
Assert(initialized.ValueKind == JsonValueKind.True, "Expected initialized=true from db.initialize_schema.");
Assert(data.TryGetProperty("databasePath", out var databasePath), "Expected databasePath in db.initialize_schema response.");
Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty databasePath from db.initialize_schema.");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema.");
var data = doc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("schemaPath", out var schemaPath), "Expected schemaPath in db.initialize_schema response.");
Assert(schemaPath.ValueKind == JsonValueKind.String, "Expected string schemaPath value.");
var resolvedPath = schemaPath.GetString() ?? "";
Assert(File.Exists(resolvedPath), "db.initialize_schema should write schema bootstrap file.");
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()
{
var entry = NewEntry(unlocked: false);
var request = JsonSerializer.Serialize(new
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
action = "db.hydrate_workspace",
payload = new
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
{
password = "vault-pass-123"
}
});
action = "db.hydrate_workspace",
payload = new
{
password = "vault-pass-123",
dataDirectory = root
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.hydrate_workspace.");
var data = doc.RootElement.GetProperty("data");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.hydrate_workspace.");
var data = doc.RootElement.GetProperty("data");
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(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload.");
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(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds.");
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(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(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() == 2, "Expected hydrate to count markdown files in workspace.");
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static Task TestConfigServiceParityKeysAsync()
{
IJournalConfigService config = NewConfigService();
IJournalConfigService config = new JournalConfigService();
var current = config.Current;
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.DataDirectory), "Config DataDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.DatabaseFilename), "Config DatabaseFilename should not be empty.");
Assert(current.MonthlyVaultFormat == "%Y-%m.vault", "Config MonthlyVaultFormat should match Python format token.");
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.");
@ -137,10 +194,10 @@ internal static partial class Program
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get.");
Assert(data.TryGetProperty("VaultDirectory", out var vaultDirectory), "Expected VaultDirectory in config payload.");
Assert(vaultDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(vaultDirectory.GetString()), "Expected non-empty VaultDirectory value.");
Assert(data.TryGetProperty("DatabaseFilename", out var databaseFilename), "Expected DatabaseFilename in config payload.");
Assert(databaseFilename.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databaseFilename.GetString()), "Expected non-empty DatabaseFilename value.");
Assert(data.TryGetProperty("DataDirectory", out var dataDirectory), "Expected DataDirectory in config payload.");
Assert(dataDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(dataDirectory.GetString()), "Expected non-empty DataDirectory value.");
Assert(data.TryGetProperty("MonthlyVaultFormat", out var monthlyVaultFormat), "Expected MonthlyVaultFormat in config payload.");
Assert(monthlyVaultFormat.GetString() == "%Y-%m.vault", "Expected Python-compatible MonthlyVaultFormat value.");
Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload.");
Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload.");
}
@ -176,7 +233,7 @@ internal static partial class Program
{
action = "entries.save",
mode = "Daily",
filePath = "db://entry/2026-02-24.md"
filePath = "E:/journal/2026-02-24.md"
});
var redacted = LogRedactor.RedactPayload(payload);
@ -189,3 +246,4 @@ internal static partial class Program
return Task.CompletedTask;
}
}

View File

@ -47,386 +47,507 @@ internal static partial class Program
static async Task TestEntryEntriesSaveMergeAsync()
{
var entry = NewEntry();
var firstPath = await SaveEntryForTestAsync(entry, "2026-02-22", """
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var filePath = Path.Combine(root, "2026-02-22.md");
File.WriteAllText(filePath, """
Date: 2026-02-22
## Summary
old summary text
""");
var mergeRequest = JsonSerializer.Serialize(new
{
action = "entries.save",
payload = new
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
filePath = firstPath,
mode = "Daily",
content = """
action = "entries.save",
payload = new
{
filePath,
mode = "Daily",
content = """
Date: 2026-02-22
## Summary
new summary text
## Reflection
new reflection text
"""
}
});
}
});
var mergeResponse = await entry.HandleCommandAsync(mergeRequest);
using var mergeDoc = JsonDocument.Parse(mergeResponse);
Assert(mergeDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save daily merge.");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save.");
var loadedAfterMerge = await LoadEntryForTestAsync(entry, firstPath);
Assert(loadedAfterMerge.Contains("new summary text", StringComparison.Ordinal), "Expected merged entry to contain new summary text.");
Assert(!loadedAfterMerge.Contains("old summary text", StringComparison.Ordinal), "Expected merged entry to replace old summary section.");
Assert(loadedAfterMerge.Contains("new reflection text", StringComparison.Ordinal), "Expected merged entry to contain new reflection section.");
var saved = File.ReadAllText(filePath);
Assert(saved.Contains("new summary text", StringComparison.Ordinal), "Expected merged file to contain new summary text.");
Assert(!saved.Contains("old summary text", StringComparison.Ordinal), "Expected merged file to replace old summary section.");
Assert(saved.Contains("new reflection text", StringComparison.Ordinal), "Expected merged file to contain new reflection section.");
var fragmentSaveRequest = JsonSerializer.Serialize(new
{
action = "entries.save",
payload = new
var fragmentSaveRequest = JsonSerializer.Serialize(new
{
filePath = firstPath,
mode = "Fragment",
content = "!NOTE\nfragment append text"
}
});
action = "entries.save",
payload = new
{
filePath,
mode = "Fragment",
content = "!NOTE\nfragment append text"
}
});
var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest);
using var fragmentDoc = JsonDocument.Parse(fragmentResponse);
Assert(fragmentDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save fragment mode.");
var loadedAfterFragment = await LoadEntryForTestAsync(entry, firstPath);
Assert(loadedAfterFragment.Contains("fragment append text", StringComparison.Ordinal), "Expected fragment append text in saved entry.");
var fragmentResponse = await entry.HandleCommandAsync(fragmentSaveRequest);
using var fragmentDoc = JsonDocument.Parse(fragmentResponse);
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryEntriesLoadAsync()
{
var entry = NewEntry();
var content = """
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var filePath = Path.Combine(root, "2026-02-22.md");
var content = """
Date: 2026-02-22
## Summary
hello world
""";
var filePath = await SaveEntryForTestAsync(entry, "2026-02-22", content);
File.WriteAllText(filePath, content);
var request = JsonSerializer.Serialize(new
{
action = "entries.load",
payload = new
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
filePath
}
});
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 entries.load.");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.load.");
var data = doc.RootElement.GetProperty("data");
var entryDto = data.GetProperty("Entry");
Assert(entryDto.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date 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.");
var data = doc.RootElement.GetProperty("data");
var entryDto = data.GetProperty("Entry");
Assert(entryDto.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date 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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryEntriesListAsync()
{
var entry = NewEntry();
await SaveEntryForTestAsync(entry, "2026-02-03", "c");
await SaveEntryForTestAsync(entry, "2026-02-01", "a");
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "entries.list",
payload = new { }
});
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 response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "entries.list",
payload = new
{
dataDirectory = root
}
});
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array.");
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[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name.");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array.");
Assert(data.GetArrayLength() == 2, "Expected entries.list to return only markdown files.");
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryTemplatesCrudExcludesFromEntriesListAsync()
{
var entry = NewEntry();
await SaveEntryForTestAsync(entry, "2026-02-03", "daily entry");
var root = Path.Combine(Path.GetTempPath(), "journal-template-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var saveRequest = JsonSerializer.Serialize(new
try
{
action = "templates.save",
payload = new
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "daily entry");
var entry = NewEntry();
var saveRequest = JsonSerializer.Serialize(new
{
name = "Weekly Review",
content = "# Weekly Review\n\n## Wins\n- one"
}
});
var saveResponse = await entry.HandleCommandAsync(saveRequest);
using var saveDoc = JsonDocument.Parse(saveResponse);
Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save.");
action = "templates.save",
payload = new
{
name = "Weekly Review",
content = "# Weekly Review\n\n## Wins\n- one",
dataDirectory = root
}
});
var saveResponse = await entry.HandleCommandAsync(saveRequest);
using var saveDoc = JsonDocument.Parse(saveResponse);
Assert(saveDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.save.");
var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(templatePath.EndsWith(".template.md", StringComparison.OrdinalIgnoreCase), "Template file should end with .template.md.");
Assert(File.Exists(templatePath), "Template file should exist.");
var templatePath = saveDoc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(templatePath.EndsWith("Weekly%20Review.template.md", StringComparison.OrdinalIgnoreCase), "Template path should be canonical db://template path.");
var listTemplatesRequest = JsonSerializer.Serialize(new
{
action = "templates.list",
payload = new { }
});
var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest);
using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse);
Assert(listTemplatesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.list.");
var templateItems = listTemplatesDoc.RootElement.GetProperty("data");
Assert(templateItems.GetArrayLength() == 1, "Expected one template in templates.list.");
Assert(templateItems[0].GetProperty("FileName").GetString() == "Weekly Review.template.md", "Template file name mismatch.");
var listEntriesRequest = JsonSerializer.Serialize(new
{
action = "entries.list",
payload = new { }
});
var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest);
using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse);
Assert(listEntriesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var entryItems = listEntriesDoc.RootElement.GetProperty("data");
Assert(entryItems.GetArrayLength() == 1, "Expected entries.list to exclude template files.");
Assert(entryItems[0].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected only daily entry file in entries.list.");
var loadTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.load",
payload = new
var listTemplatesRequest = JsonSerializer.Serialize(new
{
filePath = templatePath
}
});
var loadTemplateResponse = await entry.HandleCommandAsync(loadTemplateRequest);
using var loadTemplateDoc = JsonDocument.Parse(loadTemplateResponse);
Assert(loadTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.load.");
var content = loadTemplateDoc.RootElement.GetProperty("data").GetProperty("Content").GetString() ?? "";
Assert(content.Contains("## Wins", StringComparison.Ordinal), "Expected template content in templates.load result.");
action = "templates.list",
payload = new
{
dataDirectory = root
}
});
var listTemplatesResponse = await entry.HandleCommandAsync(listTemplatesRequest);
using var listTemplatesDoc = JsonDocument.Parse(listTemplatesResponse);
Assert(listTemplatesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.list.");
var templateItems = listTemplatesDoc.RootElement.GetProperty("data");
Assert(templateItems.GetArrayLength() == 1, "Expected one template in templates.list.");
Assert(templateItems[0].GetProperty("FileName").GetString() == "Weekly Review.template.md", "Template file name mismatch.");
var deleteTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.delete",
payload = new
var listEntriesRequest = JsonSerializer.Serialize(new
{
filePath = templatePath
}
});
var deleteTemplateResponse = await entry.HandleCommandAsync(deleteTemplateRequest);
using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse);
Assert(deleteTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.delete.");
Assert(deleteTemplateDoc.RootElement.GetProperty("data").GetBoolean(), "Expected templates.delete to return true.");
action = "entries.list",
payload = new
{
dataDirectory = root
}
});
var listEntriesResponse = await entry.HandleCommandAsync(listEntriesRequest);
using var listEntriesDoc = JsonDocument.Parse(listEntriesResponse);
Assert(listEntriesDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var entryItems = listEntriesDoc.RootElement.GetProperty("data");
Assert(entryItems.GetArrayLength() == 1, "Expected entries.list to exclude template files.");
Assert(entryItems[0].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected only daily entry file in entries.list.");
var loadTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.load",
payload = new
{
filePath = templatePath
}
});
var loadTemplateResponse = await entry.HandleCommandAsync(loadTemplateRequest);
using var loadTemplateDoc = JsonDocument.Parse(loadTemplateResponse);
Assert(loadTemplateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for templates.load.");
var content = loadTemplateDoc.RootElement.GetProperty("data").GetProperty("Content").GetString() ?? "";
Assert(content.Contains("## Wins", StringComparison.Ordinal), "Expected template content in templates.load result.");
var deleteTemplateRequest = JsonSerializer.Serialize(new
{
action = "templates.delete",
payload = new
{
filePath = templatePath
}
});
var deleteTemplateResponse = await entry.HandleCommandAsync(deleteTemplateRequest);
using var deleteTemplateDoc = JsonDocument.Parse(deleteTemplateResponse);
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(!File.Exists(templatePath), "Template file should be deleted.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesMatchesRawContentAsync()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
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 request = JsonSerializer.Serialize(new
{
query = "common token",
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
query = "common token",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content.");
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
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()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new { }
});
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 response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
}
});
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 3, "Expected all seeded markdown entries to be returned when query is omitted.");
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query.");
var data = doc.RootElement.GetProperty("data");
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesDateRangeFilterAsync()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
startDate = "2026-02-02",
endDate = "2026-02-28",
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
startDate = "2026-02-02",
endDate = "2026-02-28",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for date-range filtered search.");
var data = doc.RootElement.GetProperty("data");
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(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for date-range filtered search.");
var data = doc.RootElement.GetProperty("data");
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesSectionFilterAsync()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
query = "focus area",
section = "Reflection",
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
query = "focus area",
section = "Reflection",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for section-scoped search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 1, "Expected one section-scoped result.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch.");
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for section-scoped search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 1, "Expected one section-scoped result.");
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()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
tags = new[] { "stress" },
types = new[] { "!TRIGGER" },
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
tags = new[] { "stress" },
types = new[] { "!TRIGGER" },
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for fragment tag/type filtered search.");
var data = doc.RootElement.GetProperty("data");
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(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for fragment tag/type filtered search.");
var data = doc.RootElement.GetProperty("data");
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.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesCheckboxFilterAsync()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
@checked = new[] { "med taken" },
@unchecked = new[] { "drink water" },
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
@checked = new[] { "med taken" },
@unchecked = new[] { "drink water" },
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for checkbox filtered search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters.");
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for checkbox filtered search.");
var data = doc.RootElement.GetProperty("data");
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()
{
var entry = NewEntry();
await SeedSearchFixtureEntriesAsync(entry);
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = JsonSerializer.Serialize(new
try
{
action = "search.entries",
payload = new
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
startDate = "2026/02/01",
}
});
action = "search.entries",
payload = new
{
dataDirectory = root,
startDate = "2026/02/01",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false for invalid date format.");
var error = doc.RootElement.GetProperty("error").GetString() ?? "";
Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error.");
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false for invalid date format.");
var error = doc.RootElement.GetProperty("error").GetString() ?? "";
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()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
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 entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
try
{
entryFiles.SaveEntry(new EntrySavePayload("""
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"));
WriteSearchFixtureFiles(dataDir);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand(
[
"common",
"--start-date", "2026-02-01",
"--end-date", "2026-02-28",
"--tag", "stress",
"--type", "!TRIGGER",
"--checked", "med taken",
"--section", "Summary"
"--data-dir", dataDir,
"--start-date", "2026-02-01",
"--end-date", "2026-02-28",
"--tag", "stress",
"--type", "!TRIGGER",
"--checked", "med taken",
"--section", "Summary"
]));
Assert(exitCode == 0, "Expected search CLI command to succeed.");
@ -436,7 +557,6 @@ non match
}
finally
{
session.Dispose();
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
@ -447,27 +567,19 @@ non match
static Task TestSidecarSearchCliEmptyDataAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
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 entryFiles = new EntryFileService(repo);
var searchService = new EntrySearchService(repo);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService(), dbService), searchService, config, entryFiles);
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
try
{
var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand([]));
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand(["--data-dir", dataDir]));
Assert(exitCode == 0, "Expected search CLI command to return success for empty data store.");
Assert(stdout.Contains("No journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message.");
Assert(exitCode == 0, "Expected search CLI command to return success for empty data directory.");
Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message.");
}
finally
{
session.Dispose();
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
@ -477,6 +589,7 @@ non match
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 options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var element = JsonSerializer.Deserialize<JsonElement>(json);
@ -494,31 +607,26 @@ non match
static async Task TestEntrySaveWithFileNameAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
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);
Directory.CreateDirectory(root);
try
{
// Use EntryFileService directly to test the full save path with fileName
var service = new EntryFileService(new DiskEntryFileRepository());
var payload = new EntrySavePayload(
Content: "# Custom Entry\n\nHello world",
FilePath: null,
Mode: "Overwrite",
FileName: "My Custom Name");
var result = service.SaveEntry(payload);
var result = service.SaveEntry(payload, root);
Assert(result.FilePath.StartsWith("db://entry/", StringComparison.OrdinalIgnoreCase), "Expected canonical db://entry path.");
Assert(result.FilePath.Contains("My%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase), "Expected escaped custom name in db path.");
var loaded = service.LoadEntry(result.FilePath);
Assert(loaded.Entry.RawContent.Contains("Hello world", StringComparison.Ordinal), "Stored content should match.");
var expectedPath = Path.GetFullPath(Path.Combine(root, "My Custom Name.md"));
Assert(result.FilePath == expectedPath, $"Expected path '{expectedPath}' but got '{result.FilePath}'.");
Assert(File.Exists(expectedPath), "Custom-named file should exist on disk.");
Assert(File.ReadAllText(expectedPath).Contains("Hello world"), "File content should match.");
// Also test via Entry.HandleCommandAsync with JSON to verify full deserialization chain
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
@ -536,87 +644,14 @@ non match
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.save with fileName.");
var savedFilePath = doc.RootElement.GetProperty("data").GetProperty("FilePath").GetString() ?? "";
Assert(savedFilePath.Contains("Another%20Custom%20Name.md", StringComparison.OrdinalIgnoreCase),
$"Expected file path to contain 'Another%20Custom%20Name.md' but got '{savedFilePath}'.");
Assert(savedFilePath.Contains("Another Custom Name.md", StringComparison.OrdinalIgnoreCase),
$"Expected file path to contain 'Another Custom Name.md' but got '{savedFilePath}'.");
}
finally
{
session.Dispose();
if (Directory.Exists(root))
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,19 +53,20 @@ internal static partial class Program
try
{
var configService = NewConfigService(tempRoot);
// Set up encrypted DB session
var configService = new JournalConfigService();
var dbService = new JournalDatabaseService(configService);
// First session: create a fragment
using var session1 = new DatabaseSessionService(dbService);
session1.SetPassword(password);
session1.SetPassword(password, dataDir);
var repo1 = new SqliteFragmentRepository(session1);
var service1 = new FragmentService(repo1);
var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
// Second session: verify persistence
using var session2 = new DatabaseSessionService(dbService);
session2.SetPassword(password);
session2.SetPassword(password, dataDir);
var repo2 = new SqliteFragmentRepository(session2);
var service2 = new FragmentService(repo2);
var loaded = service2.GetById(created.Id);

View File

@ -6,64 +6,26 @@ internal static partial class Program
return new FragmentService(repo);
}
static Entry NewEntry(bool unlocked = true, string password = "vault-pass-123", string? root = null)
static Entry NewEntry()
{
var config = NewConfigService(root);
var dbService = new JournalDatabaseService(config);
var dbService = new JournalDatabaseService(new JournalConfigService());
var session = new DatabaseSessionService(dbService);
if (unlocked)
session.SetPassword(password);
var entryRepo = new SqliteEntryFileRepository(session);
return new Entry(
NewService(),
new EntrySearchService(entryRepo),
new VaultStorageService(new VaultCryptoService(), dbService),
new EntrySearchService(),
new VaultStorageService(new VaultCryptoService()),
dbService,
session,
config,
new JournalConfigService(),
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
new EntryFileService(entryRepo),
new EntryFileService(new DiskEntryFileRepository()),
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
new CommandLogger());
}
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 IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
static Dictionary<string, string> ReadVaultEntryTexts(string vaultPath, string password)
{

View File

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

View File

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