From 1e28ae9e25675b24c757f85c76488fe0683b85a6 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 26 Feb 2026 20:15:43 -0600 Subject: [PATCH] refactor(smoke-tests): split Program.cs into grouped partial files --- Journal.SmokeTests/GlobalUsings.cs | 19 + Journal.SmokeTests/Program.AiSpeechTests.cs | 342 +++ .../Program.DatabaseConfigTests.cs | 249 ++ Journal.SmokeTests/Program.EntryTests.cs | 565 +++++ Journal.SmokeTests/Program.FragmentTests.cs | 232 ++ Journal.SmokeTests/Program.ParserTests.cs | 153 ++ Journal.SmokeTests/Program.Shared.cs | 169 ++ Journal.SmokeTests/Program.TransportTests.cs | 48 + Journal.SmokeTests/Program.VaultTests.cs | 436 ++++ Journal.SmokeTests/Program.cs | 2216 +---------------- 10 files changed, 2229 insertions(+), 2200 deletions(-) create mode 100644 Journal.SmokeTests/GlobalUsings.cs create mode 100644 Journal.SmokeTests/Program.AiSpeechTests.cs create mode 100644 Journal.SmokeTests/Program.DatabaseConfigTests.cs create mode 100644 Journal.SmokeTests/Program.EntryTests.cs create mode 100644 Journal.SmokeTests/Program.FragmentTests.cs create mode 100644 Journal.SmokeTests/Program.ParserTests.cs create mode 100644 Journal.SmokeTests/Program.Shared.cs create mode 100644 Journal.SmokeTests/Program.TransportTests.cs create mode 100644 Journal.SmokeTests/Program.VaultTests.cs diff --git a/Journal.SmokeTests/GlobalUsings.cs b/Journal.SmokeTests/GlobalUsings.cs new file mode 100644 index 0000000..9fe682e --- /dev/null +++ b/Journal.SmokeTests/GlobalUsings.cs @@ -0,0 +1,19 @@ +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; +global using Journal.Core.Models; +global using Journal.Core.Repositories; +global using Journal.Core.Services.Ai; +global using Journal.Core.Services.Config; +global using Journal.Core.Services.Database; +global using Journal.Core.Services.Entries; +global using Journal.Core.Services.Fragments; +global using Journal.Core.Services.Logging; +global using Journal.Core.Services.Speech; +global using Journal.Core.Services.Sidecar; +global using Journal.Core.Services.Lists; +global using Journal.Core.Services.Todos; +global using Journal.Core.Services.Vault; diff --git a/Journal.SmokeTests/Program.AiSpeechTests.cs b/Journal.SmokeTests/Program.AiSpeechTests.cs new file mode 100644 index 0000000..50f7fce --- /dev/null +++ b/Journal.SmokeTests/Program.AiSpeechTests.cs @@ -0,0 +1,342 @@ +internal static partial class Program +{ + static async Task TestEntryAiHealthDefaultAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"ai.health"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default."); + Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'."); + } + + static async Task TestEntryAiSummarizeEntryDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_entry", + payload = new + { + content = "sample entry" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry."); + } + + static async Task TestEntryAiSummarizeAllDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.summarize_all", + payload = new + { + entries = new[] { "entry one", "entry two" } + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all."); + } + + static async Task TestEntryAiChatDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.chat", + payload = new + { + prompt = "hello cloud" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat."); + var data = doc.RootElement.GetProperty("data").GetString() ?? ""; + Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat."); + } + + static async Task TestEntryAiEmbedDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "ai.embed", + payload = new + { + content = "embedding source text" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array."); + Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector."); + } + + static async Task TestEntrySpeechDevicesListDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.devices.list", + payload = new { } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object."); + } + + static async Task TestEntrySpeechTranscribeDisabledAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "speech.transcribe", + payload = new + { + text = "fixture transcript", + engine = "whisper" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object."); + var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : ""; + Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning."); + } + + static async Task TestPythonSidecarAiServiceJsonLineAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_ai_sidecar.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +print("DEBUG prelude") +if action == "health": + print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}})) +elif action == "summarize_entry": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))})) +elif action == "summarize_all": + payload = request.get("payload") or {} + entries = payload.get("entries") or [] + print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))})) +elif action == "chat": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))})) +elif action == "embed": + payload = request.get("payload") or {} + text = str(payload.get("content", "")) + print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]})) +else: + print(json.dumps({"ok": False, "error": "unknown action"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + IAiService service = new PythonSidecarAiService(config); + + var health = await service.HealthAsync(); + Assert(health.Enabled, "Expected enabled=true for python-sidecar health."); + Assert(health.Healthy, "Expected healthy=true from fake sidecar health."); + + var one = await service.SummarizeEntryAsync("hello"); + Assert(one == "ENTRY::hello", "Unexpected summarize_entry response."); + + var all = await service.SummarizeAllAsync(["a", "b", "c"]); + Assert(all == "ALL::3", "Unexpected summarize_all response."); + + var chat = await service.ChatAsync("hello"); + Assert(chat == "CHAT::hello", "Unexpected chat response."); + + var vector = await service.EmbedAsync("hello"); + Assert(vector.Count == 3, "Unexpected embed vector length."); + Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value."); + Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value."); + Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestPythonSidecarAiServiceErrorAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py"); + File.WriteAllText(scriptPath, """ +import json, sys +_ = json.loads(sys.stdin.readline()) +print(json.dumps({"ok": False, "error": "simulated failure"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + IAiService service = new PythonSidecarAiService(config); + + try + { + _ = await service.SummarizeEntryAsync("hello"); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException("Expected summarize_entry to surface sidecar error."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestPythonSidecarSpeechServiceNoDevicesAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_nodes.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +if action == "speech.devices.list": + print(json.dumps({"ok": True, "data": {"devices": [], "warning": "no devices"}})) +elif action == "speech.transcribe": + payload = request.get("payload") or {} + print(json.dumps({"ok": True, "data": {"text": payload.get("text", ""), "engine": payload.get("engine", "whisper")}})) +else: + print(json.dumps({"ok": False, "error": "unknown action"})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + var devices = await service.ListDevicesAsync(); + Assert(devices.Devices.Count == 0, "Expected empty devices list."); + Assert((devices.Warning ?? "").Contains("no devices", StringComparison.OrdinalIgnoreCase), "Expected no-devices warning."); + + var transcript = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture text", Engine: "whisper")); + Assert(transcript.Text == "fixture text", "Expected passthrough transcript text."); + Assert(transcript.Engine == "whisper", "Expected passthrough transcript engine."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestPythonSidecarSpeechServiceErrorAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_error.py"); + File.WriteAllText(scriptPath, """ +import json, sys +request = json.loads(sys.stdin.readline()) +action = request.get("action", "") +if action == "speech.transcribe": + print(json.dumps({"ok": False, "error": "engine unavailable"})) +else: + print(json.dumps({"ok": True, "data": {"devices": []}})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 4000); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + try + { + _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", Engine: "faster-whisper")); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("engine unavailable", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException("Expected speech transcribe to surface sidecar engine error."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + static async Task TestPythonSidecarSpeechServiceTimeoutAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var scriptPath = Path.Combine(root, "fake_speech_sidecar_timeout.py"); + File.WriteAllText(scriptPath, """ +import json, sys, time +request = json.loads(sys.stdin.readline()) +payload = request.get("payload") or {} +sleep_ms = int(payload.get("simulate_delay_ms") or 0) +time.sleep(max(0, sleep_ms) / 1000.0) +print(json.dumps({"ok": True, "data": {"text": "", "engine": "whisper"}})) +"""); + + try + { + var config = BuildAiConfig(scriptPath, timeoutMs: 100); + ISpeechBridgeService service = new PythonSidecarSpeechService(config); + + try + { + _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", SimulateDelayMs: 500)); + } + catch (TimeoutException) + { + return; + } + + throw new InvalidOperationException("Expected speech transcribe timeout path."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} + diff --git a/Journal.SmokeTests/Program.DatabaseConfigTests.cs b/Journal.SmokeTests/Program.DatabaseConfigTests.cs new file mode 100644 index 0000000..2b51b89 --- /dev/null +++ b/Journal.SmokeTests/Program.DatabaseConfigTests.cs @@ -0,0 +1,249 @@ +internal static partial class Program +{ + static Task TestDatabaseKeyDerivationMatchesPythonAsync() + { + var service = NewDatabaseService(); + var keyHex = Convert.ToHexString(service.DeriveDatabaseKey("vault-pass-123")).ToLowerInvariant(); + var expected = "6a9de08e13357aa8f14e7eb0ccde119e7b4d277c60aaaca6493d9a1e1eaa5b04"; + Assert(keyHex == expected, "Database key derivation should match Python PBKDF2 fixture."); + return Task.CompletedTask; + } + + static Task TestDatabaseSchemaParityAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var service = NewDatabaseService(); + var schemaPath = service.WriteSchemaBootstrap(root); + var statements = service.GetSchemaStatements(); + var tableNames = new HashSet(statements.Keys, StringComparer.OrdinalIgnoreCase); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + 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("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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); + 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 + { + 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"); + + 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 = 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(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."); + Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch."); + Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch."); + Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch."); + Assert(current.AiProvider == "none", "Config AiProvider default mismatch."); + Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch."); + Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch."); + Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch."); + + return Task.CompletedTask; + } + + static async Task TestEntryConfigGetAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"config.get"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get."); + var data = doc.RootElement.GetProperty("data"); + Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get."); + + 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."); + } + + static Task TestLogRedactorScrubsSensitiveFieldsAsync() + { + var payload = JsonSerializer.SerializeToElement(new + { + password = "vault-pass-123", + content = "private journal body", + prompt = "private ai prompt", + nested = new + { + token = "abc123" + } + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted."); + Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted."); + Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted."); + Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted."); + Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present."); + + return Task.CompletedTask; + } + + static Task TestLogRedactorPreservesNonSensitiveFieldsAsync() + { + var payload = JsonSerializer.SerializeToElement(new + { + action = "entries.save", + mode = "Daily", + filePath = "E:/journal/2026-02-24.md" + }); + + var redacted = LogRedactor.RedactPayload(payload); + var serialized = JsonSerializer.Serialize(redacted); + + Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved."); + Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved."); + Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved."); + + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.EntryTests.cs b/Journal.SmokeTests/Program.EntryTests.cs new file mode 100644 index 0000000..974f2cb --- /dev/null +++ b/Journal.SmokeTests/Program.EntryTests.cs @@ -0,0 +1,565 @@ +internal static partial class Program +{ + static async Task TestEntryUnknownActionAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"unknown.action"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Unknown action"), "Expected unknown action error."); + } + + static async Task TestEntryInvalidJsonAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("{\"action\":\"fragments.list\""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid command JSON"), "Expected invalid JSON error."); + } + + static async Task TestEntryGetMissingReturnsNullDataAsync() + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "fragments.get", + id = Guid.NewGuid().ToString(), + }); + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + + Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true."); + Assert(doc.RootElement.GetProperty("data").ValueKind == JsonValueKind.Null, "Expected data=null for missing fragment."); + } + + static async Task TestEntryCreateMissingPayloadAsync() + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync("""{"action":"fragments.create"}"""); + using var doc = JsonDocument.Parse(response); + + Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); + Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("payload", StringComparison.OrdinalIgnoreCase), "Expected payload validation error."); + } + + static async Task TestEntryEntriesSaveMergeAsync() + { + 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 entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "entries.save", + payload = new + { + filePath, + mode = "Daily", + content = """ +Date: 2026-02-22 +## Summary +new summary text +## Reflection +new reflection text +""" + } + }); + + 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 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 + { + 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 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 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 +"""; + File.WriteAllText(filePath, content); + + var entry = NewEntry(); + 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 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 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 request = JsonSerializer.Serialize(new + { + action = "entries.list", + payload = new + { + dataDirectory = root + } + }); + + 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 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 request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + query = "common token", + } + }); + + 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."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + 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 request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + } + }); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + 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); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + query = "focus area", + section = "Reflection", + } + }); + + 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."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + 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 request = JsonSerializer.Serialize(new + { + 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); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + 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); + + 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + WriteSearchFixtureFiles(root); + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "search.entries", + payload = new + { + dataDirectory = root, + startDate = "2026/02/01", + } + }); + + 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."); + } + 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 dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + + try + { + WriteSearchFixtureFiles(dataDir); + var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); + + var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand( + [ + "common", + "--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."); + Assert(string.IsNullOrWhiteSpace(stderr), "Expected no stderr output for successful search CLI command."); + Assert(stdout.Contains("--- 2026-02-01 ---", StringComparison.Ordinal), "Expected matching entry header in search CLI output."); + Assert(!stdout.Contains("--- 2026-02-05 ---", StringComparison.Ordinal), "Unexpected non-matching entry in filtered search CLI output."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestSidecarSearchCliEmptyDataAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + + try + { + 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 directory."); + Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestEntrySavePayloadFileNameDeserializationAsync() + { + // Simulate what DeserializePayload does: parse JSON to JsonElement, then Deserialize + var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}"""; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var element = JsonSerializer.Deserialize(json); + var payload = element.Deserialize(options); + + Assert(payload is not null, "Payload should not be null."); + Assert(payload!.Content == "hello", "Content should be deserialized."); + Assert(payload.Mode == "Overwrite", "Mode should be deserialized."); + Assert(payload.FileName == "My Custom Name", "FileName should be deserialized from camelCase JSON via JsonElement."); + Assert(payload.FilePath is null, "FilePath should be null when not provided."); + + return Task.CompletedTask; + } + + static async Task TestEntrySaveWithFileNameAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); + 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, root); + + 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 + { + action = "entries.save", + payload = new + { + content = "# Second Entry", + mode = "Overwrite", + fileName = "Another Custom Name" + } + }); + + var response = await entry.HandleCommandAsync(request); + using var doc = JsonDocument.Parse(response); + 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 Custom Name.md", StringComparison.OrdinalIgnoreCase), + $"Expected file path to contain 'Another Custom Name.md' but got '{savedFilePath}'."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} + diff --git a/Journal.SmokeTests/Program.FragmentTests.cs b/Journal.SmokeTests/Program.FragmentTests.cs new file mode 100644 index 0000000..c0e1f08 --- /dev/null +++ b/Journal.SmokeTests/Program.FragmentTests.cs @@ -0,0 +1,232 @@ +internal static partial class Program +{ + static Task TestCreateTrimsAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); + + Assert(created.Type == "!TRIGGER", "Type should be trimmed."); + Assert(created.Description == "stomach drop", "Description should be trimmed."); + Assert(created.Tags.Count == 2, "Expected two normalized tags."); + Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); + return Task.CompletedTask; + } + + static Task TestUpdateAcceptsTypeAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "one")); + var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); + + Assert(ok, "Expected update to succeed."); + var updated = service.GetById(created.Id); + Assert(updated is not null, "Updated fragment should exist."); + Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored."); + Assert(updated.Description == "two", "Updated description should be trimmed and stored."); + Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized."); + return Task.CompletedTask; + } + + static Task TestUpdateRejectsWhitespaceTypeAsync() + { + var service = NewService(); + var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc")); + + try + { + _ = service.Update(created.Id, new UpdateFragmentDto(Type: " ")); + } + catch (ValidationException) + { + return Task.CompletedTask; + } + + throw new InvalidOperationException("Expected ValidationException for whitespace type update."); + } + + static Task TestFileRepositoryPersistsAsync() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(tempRoot, "data"); + Directory.CreateDirectory(dataDir); + const string password = "smoke-test-password"; + + try + { + // 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, 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, dataDir); + var repo2 = new SqliteFragmentRepository(session2); + var service2 = new FragmentService(repo2); + var loaded = service2.GetById(created.Id); + + Assert(loaded is not null, "Expected fragment to persist across repository instances."); + Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); + Assert(loaded.Tags.Count == 1 && loaded.Tags[0] == "tag1", "Persisted tags mismatch."); + } + finally + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestJournalEntryModelAsync() + { + var fragment = new Fragment("!TRIGGER", "test fragment"); + var section = new ParsedSection( + "Summary", + content: ["line one", "- [x] completed thing"], + checkboxes: new Dictionary { ["completed thing"] = true }); + + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment], + rawContent: "raw markdown content", + sections: new Dictionary { ["Summary"] = section }); + + Assert(entry.Date == "2026-02-22", "JournalEntry date mismatch."); + Assert(entry.RawContent == "raw markdown content", "JournalEntry raw content mismatch."); + Assert(entry.Fragments.Count == 1, "JournalEntry fragment count mismatch."); + Assert(entry.Sections.Count == 1, "JournalEntry section count mismatch."); + Assert(entry.GetSection("Summary").Contains("line one"), "JournalEntry section content mismatch."); + Assert(entry.GetCheckboxState("Summary", "completed thing") is true, "JournalEntry checkbox state mismatch."); + Assert(entry.GetCheckboxState("Summary", "missing") is null, "JournalEntry checkbox should return null when missing."); + + return Task.CompletedTask; + } + + static Task TestMergeOverwritesMeaningfulSectionAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary + { + ["Summary"] = new ParsedSection("Summary", ["old content"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary + { + ["Summary"] = new ParsedSection( + "Summary", + [" ", "new content line"], + new Dictionary { ["new check"] = true }), + ["Reflection"] = new ParsedSection("Reflection", ["reflective note"]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("new content line"), "Meaningful section update should overwrite existing section."); + Assert(!current.GetSection("Summary").Contains("old content"), "Old section content should be replaced."); + Assert(current.GetCheckboxState("Summary", "new check") is true, "Overwritten section checkbox state should come from incoming section."); + Assert(current.GetSection("Reflection").Contains("reflective note"), "Meaningful new section should be added."); + + return Task.CompletedTask; + } + + static Task TestMergeIgnoresWhitespaceOnlySectionAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary + { + ["Summary"] = new ParsedSection("Summary", ["keep existing"]) + }); + + var incoming = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary + { + ["Summary"] = new ParsedSection("Summary", [" ", "\t", ""]) + }); + + current.MergeWith(incoming); + + Assert(current.GetSection("Summary").Contains("keep existing"), "Whitespace-only section update should be ignored."); + + return Task.CompletedTask; + } + + static Task TestMergeAppendsNonDuplicateFragmentsAsync() + { + var current = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!TRIGGER", "duplicate description") + ]); + + var incoming = new JournalEntry( + date: "2026-02-22", + fragments: + [ + new Fragment("!NOTE", "duplicate description"), + new Fragment("!NOTE", "new description") + ]); + + current.MergeWith(incoming); + + Assert(current.Fragments.Count == 2, "Expected only one new fragment to be appended."); + Assert(current.Fragments.Count(fragment => fragment.Description == "duplicate description") == 1, "Duplicate description should not be appended."); + Assert(current.Fragments.Any(fragment => fragment.Description == "new description"), "New fragment description should be appended."); + + return Task.CompletedTask; + } + + static Task TestToMarkdownCanonicalSectionOrderAsync() + { + var entry = new JournalEntry( + date: "2026-02-22", + sections: new Dictionary + { + ["Reflection"] = new ParsedSection("Reflection", ["reflection body"]), + ["Summary"] = new ParsedSection("Summary", ["summary body"]) + }); + + var markdown = entry.ToMarkdown(); + var summaryIdx = markdown.IndexOf("## Summary", StringComparison.Ordinal); + var reflectionIdx = markdown.IndexOf("## Reflection", StringComparison.Ordinal); + + Assert(summaryIdx >= 0, "Summary header should be emitted."); + Assert(reflectionIdx >= 0, "Reflection header should be emitted."); + Assert(summaryIdx < reflectionIdx, "Sections should be emitted in canonical order."); + + return Task.CompletedTask; + } + + static Task TestToMarkdownFragmentFormattingAsync() + { + var fragment = new Fragment("!TRIGGER", "fragment body") + { + Time = default, + Tags = ["stress", "body"] + }; + var entry = new JournalEntry( + date: "2026-02-22", + fragments: [fragment]); + + var markdown = entry.ToMarkdown(); + + Assert(markdown.Contains("# Fragments\n", StringComparison.Ordinal), "Fragments header should be present."); + Assert(markdown.Contains("!TRIGGER #stress #body\nfragment body\n", StringComparison.Ordinal), "Fragment block format should match parity shape."); + Assert(markdown.Contains("**Date:** 2026-02-22", StringComparison.Ordinal), "Date frontmatter line should be present."); + + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.ParserTests.cs b/Journal.SmokeTests/Program.ParserTests.cs new file mode 100644 index 0000000..df34165 --- /dev/null +++ b/Journal.SmokeTests/Program.ParserTests.cs @@ -0,0 +1,153 @@ +internal static partial class Program +{ + static Task TestParserExtractsBoldDateAsync() + { + var content = """ + --- + type: journal + --- + **Date:** 2026-02-22 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-22", "Parser should read date from **Date:** marker."); + return Task.CompletedTask; + } + + static Task TestParserExtractsPlainDateAsync() + { + var content = """ + Date: 2026-02-23 + ## Summary + hello + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + Assert(entry.Date == "2026-02-23", "Parser should read date from Date: marker."); + return Task.CompletedTask; + } + + static Task TestParserFallsBackToFileStemAsync() + { + var content = """ + ## Summary + no explicit date + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-24"); + Assert(entry.Date == "2026-02-24", "Parser should fall back to file stem when no date marker is present."); + return Task.CompletedTask; + } + + static Task TestParserCapturesSectionsAsync() + { + var content = """ + Date: 2026-02-25 + ## Summary + line one + line two + ### Events / Triggers - Work + trigger line + ## reflection notes + anchor line + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Sections.ContainsKey("Summary"), "Parser should capture Summary section."); + Assert(entry.Sections.ContainsKey("Events / Triggers"), "Parser should capture Events / Triggers section."); + Assert(entry.Sections.ContainsKey("Reflection"), "Parser should match canonical section title by substring."); + Assert(entry.GetSection("Summary").Contains("line one"), "Summary section content mismatch."); + Assert(entry.GetSection("Events / Triggers").Contains("trigger line"), "Events / Triggers section content mismatch."); + Assert(entry.GetSection("Reflection").Contains("anchor line"), "Reflection section content mismatch."); + + return Task.CompletedTask; + } + + static Task TestParserIgnoresNonCanonicalHeadersAsync() + { + var content = """ + ## Summary + keep this + ## Totally Custom Header + should not be captured + ### Events / Triggers + keep this too + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetSection("Summary").Contains("keep this"), "Summary section should be captured."); + Assert(!entry.GetSection("Summary").Contains("should not be captured"), "Non-canonical section content should not bleed into previous section."); + Assert(entry.GetSection("Events / Triggers").Contains("keep this too"), "Canonical section after custom header should be captured."); + + return Task.CompletedTask; + } + + static Task TestParserCapturesCheckboxStatesAsync() + { + var content = """ + ## Summary + - [x] took medication + - [ ] drank water + * [X] wrote reflection + ## Events / Triggers + - [ ] talked to manager + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.GetCheckboxState("Summary", "took medication") is true, "Expected checked state for '- [x]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "drank water") is false, "Expected unchecked state for '- [ ]' checkbox."); + Assert(entry.GetCheckboxState("Summary", "wrote reflection") is true, "Expected checked state for '* [X]' checkbox."); + Assert(entry.GetCheckboxState("Events / Triggers", "talked to manager") is false, "Expected unchecked state in Events / Triggers section."); + Assert(entry.GetCheckboxState("Summary", "missing item") is null, "Missing checkbox text should return null."); + + return Task.CompletedTask; + } + + static Task TestParserCapturesMultilineFragmentsAsync() + { + var content = """ + Date: 2026-02-26 + ## Summary + text + !TRIGGER @2026-02-26T10:15:00Z #stress #body + first line + second line + !NOTE #daily + short note + """; + + var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); + + Assert(entry.Fragments.Count == 2, "Expected two parsed fragments."); + Assert(entry.Fragments[0].Type == "!TRIGGER", "First fragment type mismatch."); + Assert(entry.Fragments[0].Description == "first line\nsecond line", "First fragment multiline description mismatch."); + Assert(entry.Fragments[0].Tags.Count == 2, "First fragment tag count mismatch."); + Assert(entry.Fragments[0].Tags[0] == "stress" && entry.Fragments[0].Tags[1] == "body", "First fragment tags mismatch."); + Assert(entry.Fragments[1].Type == "!NOTE", "Second fragment type mismatch."); + Assert(entry.Fragments[1].Description == "short note", "Second fragment description mismatch."); + Assert(entry.Fragments[1].Tags.Count == 1 && entry.Fragments[1].Tags[0] == "daily", "Second fragment tags mismatch."); + + return Task.CompletedTask; + } + + static Task TestParserFragmentBoundaryBehaviorAsync() + { + var content = """ + !TRIGGER #a + line one + !NOTE this starts another fragment header + line two + """; + + var fragments = JournalParser.ParseFragments(content); + Assert(fragments.Count == 1, "Expected one parsed fragment because second boundary line is not a valid fragment header."); + Assert(fragments[0].Description == "line one", "First fragment boundary capture mismatch."); + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.Shared.cs b/Journal.SmokeTests/Program.Shared.cs new file mode 100644 index 0000000..21803d9 --- /dev/null +++ b/Journal.SmokeTests/Program.Shared.cs @@ -0,0 +1,169 @@ +internal static partial class Program +{ + static FragmentService NewService() + { + IFragmentRepository repo = new InMemoryFragmentRepository(); + return new FragmentService(repo); + } + + static Entry NewEntry() + { + var dbService = new JournalDatabaseService(new JournalConfigService()); + var session = new DatabaseSessionService(dbService); + return new Entry( + NewService(), + new EntrySearchService(), + new VaultStorageService(new VaultCryptoService()), + dbService, + session, + new JournalConfigService(), + new DisabledAiService("none"), + new DisabledSpeechBridgeService("none"), + new EntryFileService(new DiskEntryFileRepository()), + new ListService(new SqliteListRepository(session)), + new TodoService(new SqliteTodoRepository(session)), + new CommandLogger()); + } + + static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); + + static Dictionary ReadVaultEntryTexts(string vaultPath, string password) + { + var crypto = new VaultCryptoService(); + var encrypted = File.ReadAllBytes(vaultPath); + var zipBytes = crypto.DecryptData(encrypted, password); + + using var stream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + using var reader = new StreamReader(entry.Open()); + result[entry.Name] = reader.ReadToEnd(); + } + + return result; + } + + static byte[] CreateZipBytes(Dictionary files) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in files) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + return stream.ToArray(); + } + + static void WriteSearchFixtureFiles(string root) + { + File.WriteAllText(Path.Combine(root, "2026-02-01.md"), """ +Date: 2026-02-01 +## Summary +Alpha common +## Reflection +focus area +- [x] med taken +!TRIGGER #stress +fragment one +"""); + + File.WriteAllText(Path.Combine(root, "2026-02-05.md"), """ +Date: 2026-02-05 +## Summary +Beta common +## Reflection +other notes +- [ ] drink water +!NOTE #daily +fragment two +"""); + + File.WriteAllText(Path.Combine(root, "2026-03-01.md"), """ +Date: 2026-03-01 +## Summary +Gamma unique +## Reflection +nothing related +!NOTE #other +fragment three +"""); + } + + static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + static JournalConfig BuildAiConfig(string sidecarScriptPath, int timeoutMs) + { + var baseConfig = new JournalConfigService().Current; + var pythonExe = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE"); + if (string.IsNullOrWhiteSpace(pythonExe)) + pythonExe = "python"; + + return baseConfig with + { + AiProvider = "python-sidecar", + PythonExecutable = pythonExe, + PythonAiSidecarPath = sidecarScriptPath, + AiSidecarTimeoutMs = timeoutMs + }; + } + + static async Task> LoadTransportFixturesAsync() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "transport_cases.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Transport fixture file not found: {path}"); + + var json = await File.ReadAllTextAsync(path); + return JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? []; + } + + static JsonValueKind ParseValueKind(string value) => value.Trim().ToLowerInvariant() switch + { + "array" => JsonValueKind.Array, + "object" => JsonValueKind.Object, + "null" => JsonValueKind.Null, + "string" => JsonValueKind.String, + "number" => JsonValueKind.Number, + "true" => JsonValueKind.True, + "false" => JsonValueKind.False, + _ => throw new InvalidOperationException($"Unsupported JsonValueKind '{value}' in transport fixture.") + }; + + static void Assert(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} + diff --git a/Journal.SmokeTests/Program.TransportTests.cs b/Journal.SmokeTests/Program.TransportTests.cs new file mode 100644 index 0000000..f24d5a7 --- /dev/null +++ b/Journal.SmokeTests/Program.TransportTests.cs @@ -0,0 +1,48 @@ +internal static partial class Program +{ + static async Task TestTransportFixturesAsync() + { + var fixtures = await LoadTransportFixturesAsync(); + Assert(fixtures.Count > 0, "Transport fixtures should not be empty."); + + foreach (var fixture in fixtures) + { + var entry = NewEntry(); + var response = await entry.HandleCommandAsync(fixture.Request); + + Assert(!response.Contains('\n') && !response.Contains('\r'), $"Fixture '{fixture.Name}' returned multiline output."); + + using var doc = JsonDocument.Parse(response); + var ok = doc.RootElement.GetProperty("ok").GetBoolean(); + Assert(ok == fixture.ExpectOk, $"Fixture '{fixture.Name}' expected ok={fixture.ExpectOk} but got ok={ok}."); + + if (fixture.ExpectOk) + { + Assert(doc.RootElement.TryGetProperty("data", out var data), $"Fixture '{fixture.Name}' expected data field."); + if (!string.IsNullOrWhiteSpace(fixture.DataKind)) + { + var expectedKind = ParseValueKind(fixture.DataKind!); + Assert(data.ValueKind == expectedKind, $"Fixture '{fixture.Name}' expected data kind {expectedKind} but got {data.ValueKind}."); + } + continue; + } + + Assert(doc.RootElement.TryGetProperty("error", out var error), $"Fixture '{fixture.Name}' expected error field."); + if (!string.IsNullOrWhiteSpace(fixture.ErrorContains)) + { + var message = error.GetString() ?? ""; + Assert(message.Contains(fixture.ErrorContains!, StringComparison.OrdinalIgnoreCase), $"Fixture '{fixture.Name}' expected error containing '{fixture.ErrorContains}'."); + } + } + } +} + +sealed class TransportFixture +{ + public string Name { get; init; } = ""; + public string Request { get; init; } = ""; + public bool ExpectOk { get; init; } + public string? DataKind { get; init; } + public string? ErrorContains { get; init; } +} + diff --git a/Journal.SmokeTests/Program.VaultTests.cs b/Journal.SmokeTests/Program.VaultTests.cs new file mode 100644 index 0000000..d24029b --- /dev/null +++ b/Journal.SmokeTests/Program.VaultTests.cs @@ -0,0 +1,436 @@ +internal static partial class Program +{ + static Task TestVaultCryptoRoundtripAsync() + { + var crypto = new VaultCryptoService(); + var plaintext = "sample vault payload"; + var payload = crypto.EncryptData(System.Text.Encoding.UTF8.GetBytes(plaintext), "vault-pass-123"); + + Assert(payload.Length == VaultCryptoService.SaltSize + VaultCryptoService.NonceSize + VaultCryptoService.TagSize + plaintext.Length, "Vault payload length should match salt+nonce+tag+ciphertext layout."); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + Assert(System.Text.Encoding.UTF8.GetString(decrypted) == plaintext, "Vault roundtrip decrypt should return original plaintext."); + + return Task.CompletedTask; + } + + static Task TestVaultCryptoDecryptsPythonFixtureAsync() + { + var crypto = new VaultCryptoService(); + + var payload = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKC6AErhDEMERBl7OFkG4L4oZ2JZckS0VzhxaZoVLckF7VXE+NIYXILsJ8f1I="); + var expectedPlaintext = Convert.FromBase64String("dmF1bHQgcGF5bG9hZCBleGFtcGxlCmxpbmUy"); + var decrypted = crypto.DecryptData(payload, "vault-pass-123"); + + Assert(decrypted.SequenceEqual(expectedPlaintext), "C# decrypt should match Python-generated payload plaintext."); + + return Task.CompletedTask; + } + + static Task TestVaultKeyDerivationMatchesPythonAsync() + { + var crypto = new VaultCryptoService(); + var salt = Enumerable.Range(0, VaultCryptoService.SaltSize).Select(i => (byte)i).ToArray(); + var key = crypto.DeriveKey("vault-pass-123", salt); + var expectedKeyHex = "b29f523f28bf178f6815c6ca9ee2a588d79b3bd9a822c92a2f0dde5bc853bb52"; + var actualKeyHex = Convert.ToHexString(key).ToLowerInvariant(); + + Assert(actualKeyHex == expectedKeyHex, "Derived key should match Python PBKDF2 fixture key."); + + return Task.CompletedTask; + } + + static Task TestVaultMonthlyFilenameParityAsync() + { + 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 vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "old_file.md"), "stale"); + + var zipBytes = CreateZipBytes(new Dictionary + { + ["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); + var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); + + 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 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultLoadWrongPasswordPreservesVaultAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + var zipBytes = CreateZipBytes(new Dictionary + { + ["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); + + 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."); + Assert(before.SequenceEqual(after), "Vault file bytes should remain unchanged on wrong password."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultLoadLegacyInitVaultHandlingAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + var legacyPath = Path.Combine(vaultDir, "_init_vault.vault"); + File.WriteAllBytes(legacyPath, [1, 2, 3, 4]); + + 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 removed during load."); + Assert(Directory.Exists(dataDir), "Data directory should exist after load workflow."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultCurrentMonthSaveOptimizedAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + 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"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + var now = new DateTime(2026, 2, 22, 12, 0, 0, DateTimeKind.Utc); + + var firstSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); + Assert(firstSaved, "Expected first current-month save to write vault data."); + + 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultRebuildAllVaultsAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + 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"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); + + 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultClearDataDirectoryAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(Path.Combine(dataDir, "nested")); + + try + { + 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"); + + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.ClearDataDirectory(dataDir); + + Assert(Directory.Exists(dataDir), "Data directory should be recreated after cleanup."); + Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after cleanup."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static async Task TestEntryVaultLoadAllEmptyAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + + try + { + var entry = NewEntry(); + var request = JsonSerializer.Serialize(new + { + action = "vault.load_all", + payload = new + { + password = "vault-pass-123", + vaultDirectory = vaultDir, + dataDirectory = dataDir, + } + }); + + 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + 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 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); + + 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 vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + + 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, "--data-dir", dataDir]); + + 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestSidecarVaultCliSaveAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + File.WriteAllText(Path.Combine(dataDir, "2026-02-22.md"), "entry body"); + + 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, "2026-02.vault")), "Expected monthly vault file to be written by save CLI command."); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } + + static Task TestVaultCustomEntryRoundtripAsync() + { + var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); + var vaultDir = Path.Combine(root, "vault"); + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(vaultDir); + Directory.CreateDirectory(dataDir); + + try + { + // 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"); + + // Rebuild vaults (simulates app close) + IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); + storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); + + // 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."); + + // Clear data directory (simulates app close step 2) + storage.ClearDataDirectory(dataDir); + Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after clear."); + + // Load vaults (simulates app restart) + var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); + Assert(ok, "Expected vault load to succeed."); + + // 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 + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + return Task.CompletedTask; + } +} + diff --git a/Journal.SmokeTests/Program.cs b/Journal.SmokeTests/Program.cs index c54d9b0..2e1efc6 100644 --- a/Journal.SmokeTests/Program.cs +++ b/Journal.SmokeTests/Program.cs @@ -1,25 +1,9 @@ -using System.ComponentModel.DataAnnotations; -using System.IO.Compression; -using System.Security.Cryptography; -using System.Text.Json; -using Journal.Core; -using Journal.Core.Dtos; -using Journal.Core.Models; -using Journal.Core.Repositories; -using Journal.Core.Services.Ai; -using Journal.Core.Services.Config; -using Journal.Core.Services.Database; -using Journal.Core.Services.Entries; -using Journal.Core.Services.Fragments; -using Journal.Core.Services.Logging; -using Journal.Core.Services.Speech; -using Journal.Core.Services.Sidecar; -using Journal.Core.Services.Lists; -using Journal.Core.Services.Todos; -using Journal.Core.Services.Vault; - -var tests = new List<(string Name, Func Run)> +internal static partial class Program { + private static async Task Main() + { + var tests = new List<(string Name, Func Run)> + { ("CreateAsync trims fields", TestCreateTrimsAsync), ("UpdateAsync accepts valid type updates", TestUpdateAcceptsTypeAsync), ("UpdateAsync rejects whitespace type", TestUpdateRejectsWhitespaceTypeAsync), @@ -93,2192 +77,24 @@ var tests = new List<(string Name, Func Run)> ("EntrySavePayload deserializes camelCase fileName from JsonElement", TestEntrySavePayloadFileNameDeserializationAsync), ("entries.save with fileName creates custom-named file", TestEntrySaveWithFileNameAsync), ("Vault rebuild and load preserves custom-named entries", TestVaultCustomEntryRoundtripAsync), -}; + }; -var passed = 0; -foreach (var (name, run) in tests) -{ - try - { - await run(); - Console.WriteLine($"PASS {name}"); - passed++; - } - catch (Exception ex) - { - Console.WriteLine($"FAIL {name}: {ex.Message}"); - } -} - -Console.WriteLine($"Summary: {passed}/{tests.Count} passed."); -Environment.ExitCode = passed == tests.Count ? 0 : 1; - -static FragmentService NewService() -{ - IFragmentRepository repo = new InMemoryFragmentRepository(); - return new FragmentService(repo); -} - -static Entry NewEntry() -{ - var dbService = new JournalDatabaseService(new JournalConfigService()); - var session = new DatabaseSessionService(dbService); - return new Entry( - NewService(), - new EntrySearchService(), - new VaultStorageService(new VaultCryptoService()), - dbService, - session, - new JournalConfigService(), - new DisabledAiService("none"), - new DisabledSpeechBridgeService("none"), - new EntryFileService(new DiskEntryFileRepository()), - new ListService(new SqliteListRepository(session)), - new TodoService(new SqliteTodoRepository(session)), - new CommandLogger()); -} - -static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService()); - -static Task TestCreateTrimsAsync() -{ - var service = NewService(); - var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "])); - - Assert(created.Type == "!TRIGGER", "Type should be trimmed."); - Assert(created.Description == "stomach drop", "Description should be trimmed."); - Assert(created.Tags.Count == 2, "Expected two normalized tags."); - Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved."); - return Task.CompletedTask; -} - -static Task TestUpdateAcceptsTypeAsync() -{ - var service = NewService(); - var created = service.Create(new CreateFragmentDto("!TRIGGER", "one")); - var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "])); - - Assert(ok, "Expected update to succeed."); - var updated = service.GetById(created.Id); - Assert(updated is not null, "Updated fragment should exist."); - Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored."); - Assert(updated.Description == "two", "Updated description should be trimmed and stored."); - Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized."); - return Task.CompletedTask; -} - -static Task TestUpdateRejectsWhitespaceTypeAsync() -{ - var service = NewService(); - var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc")); - - try - { - _ = service.Update(created.Id, new UpdateFragmentDto(Type: " ")); - } - catch (ValidationException) - { - return Task.CompletedTask; - } - - throw new InvalidOperationException("Expected ValidationException for whitespace type update."); -} - -static Task TestFileRepositoryPersistsAsync() -{ - var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N")); - var dataDir = Path.Combine(tempRoot, "data"); - Directory.CreateDirectory(dataDir); - const string password = "smoke-test-password"; - - try - { - // 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, 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, dataDir); - var repo2 = new SqliteFragmentRepository(session2); - var service2 = new FragmentService(repo2); - var loaded = service2.GetById(created.Id); - - Assert(loaded is not null, "Expected fragment to persist across repository instances."); - Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch."); - Assert(loaded.Tags.Count == 1 && loaded.Tags[0] == "tag1", "Persisted tags mismatch."); - } - finally - { - if (Directory.Exists(tempRoot)) - Directory.Delete(tempRoot, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestJournalEntryModelAsync() -{ - var fragment = new Fragment("!TRIGGER", "test fragment"); - var section = new ParsedSection( - "Summary", - content: ["line one", "- [x] completed thing"], - checkboxes: new Dictionary { ["completed thing"] = true }); - - var entry = new JournalEntry( - date: "2026-02-22", - fragments: [fragment], - rawContent: "raw markdown content", - sections: new Dictionary { ["Summary"] = section }); - - Assert(entry.Date == "2026-02-22", "JournalEntry date mismatch."); - Assert(entry.RawContent == "raw markdown content", "JournalEntry raw content mismatch."); - Assert(entry.Fragments.Count == 1, "JournalEntry fragment count mismatch."); - Assert(entry.Sections.Count == 1, "JournalEntry section count mismatch."); - Assert(entry.GetSection("Summary").Contains("line one"), "JournalEntry section content mismatch."); - Assert(entry.GetCheckboxState("Summary", "completed thing") is true, "JournalEntry checkbox state mismatch."); - Assert(entry.GetCheckboxState("Summary", "missing") is null, "JournalEntry checkbox should return null when missing."); - - return Task.CompletedTask; -} - -static Task TestMergeOverwritesMeaningfulSectionAsync() -{ - var current = new JournalEntry( - date: "2026-02-22", - sections: new Dictionary + var passed = 0; + foreach (var (name, run) in tests) { - ["Summary"] = new ParsedSection("Summary", ["old content"]) - }); - - var incoming = new JournalEntry( - date: "2026-02-22", - sections: new Dictionary - { - ["Summary"] = new ParsedSection( - "Summary", - [" ", "new content line"], - new Dictionary { ["new check"] = true }), - ["Reflection"] = new ParsedSection("Reflection", ["reflective note"]) - }); - - current.MergeWith(incoming); - - Assert(current.GetSection("Summary").Contains("new content line"), "Meaningful section update should overwrite existing section."); - Assert(!current.GetSection("Summary").Contains("old content"), "Old section content should be replaced."); - Assert(current.GetCheckboxState("Summary", "new check") is true, "Overwritten section checkbox state should come from incoming section."); - Assert(current.GetSection("Reflection").Contains("reflective note"), "Meaningful new section should be added."); - - return Task.CompletedTask; -} - -static Task TestMergeIgnoresWhitespaceOnlySectionAsync() -{ - var current = new JournalEntry( - date: "2026-02-22", - sections: new Dictionary - { - ["Summary"] = new ParsedSection("Summary", ["keep existing"]) - }); - - var incoming = new JournalEntry( - date: "2026-02-22", - sections: new Dictionary - { - ["Summary"] = new ParsedSection("Summary", [" ", "\t", ""]) - }); - - current.MergeWith(incoming); - - Assert(current.GetSection("Summary").Contains("keep existing"), "Whitespace-only section update should be ignored."); - - return Task.CompletedTask; -} - -static Task TestMergeAppendsNonDuplicateFragmentsAsync() -{ - var current = new JournalEntry( - date: "2026-02-22", - fragments: - [ - new Fragment("!TRIGGER", "duplicate description") - ]); - - var incoming = new JournalEntry( - date: "2026-02-22", - fragments: - [ - new Fragment("!NOTE", "duplicate description"), - new Fragment("!NOTE", "new description") - ]); - - current.MergeWith(incoming); - - Assert(current.Fragments.Count == 2, "Expected only one new fragment to be appended."); - Assert(current.Fragments.Count(fragment => fragment.Description == "duplicate description") == 1, "Duplicate description should not be appended."); - Assert(current.Fragments.Any(fragment => fragment.Description == "new description"), "New fragment description should be appended."); - - return Task.CompletedTask; -} - -static Task TestToMarkdownCanonicalSectionOrderAsync() -{ - var entry = new JournalEntry( - date: "2026-02-22", - sections: new Dictionary - { - ["Reflection"] = new ParsedSection("Reflection", ["reflection body"]), - ["Summary"] = new ParsedSection("Summary", ["summary body"]) - }); - - var markdown = entry.ToMarkdown(); - var summaryIdx = markdown.IndexOf("## Summary", StringComparison.Ordinal); - var reflectionIdx = markdown.IndexOf("## Reflection", StringComparison.Ordinal); - - Assert(summaryIdx >= 0, "Summary header should be emitted."); - Assert(reflectionIdx >= 0, "Reflection header should be emitted."); - Assert(summaryIdx < reflectionIdx, "Sections should be emitted in canonical order."); - - return Task.CompletedTask; -} - -static Task TestToMarkdownFragmentFormattingAsync() -{ - var fragment = new Fragment("!TRIGGER", "fragment body") - { - Time = default, - Tags = ["stress", "body"] - }; - var entry = new JournalEntry( - date: "2026-02-22", - fragments: [fragment]); - - var markdown = entry.ToMarkdown(); - - Assert(markdown.Contains("# Fragments\n", StringComparison.Ordinal), "Fragments header should be present."); - Assert(markdown.Contains("!TRIGGER #stress #body\nfragment body\n", StringComparison.Ordinal), "Fragment block format should match parity shape."); - Assert(markdown.Contains("**Date:** 2026-02-22", StringComparison.Ordinal), "Date frontmatter line should be present."); - - return Task.CompletedTask; -} - -static Task TestVaultCryptoRoundtripAsync() -{ - var crypto = new VaultCryptoService(); - var plaintext = "sample vault payload"; - var payload = crypto.EncryptData(System.Text.Encoding.UTF8.GetBytes(plaintext), "vault-pass-123"); - - Assert(payload.Length == VaultCryptoService.SaltSize + VaultCryptoService.NonceSize + VaultCryptoService.TagSize + plaintext.Length, "Vault payload length should match salt+nonce+tag+ciphertext layout."); - var decrypted = crypto.DecryptData(payload, "vault-pass-123"); - Assert(System.Text.Encoding.UTF8.GetString(decrypted) == plaintext, "Vault roundtrip decrypt should return original plaintext."); - - return Task.CompletedTask; -} - -static Task TestVaultCryptoDecryptsPythonFixtureAsync() -{ - var crypto = new VaultCryptoService(); - - var payload = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODwABAgMEBQYHCAkKC6AErhDEMERBl7OFkG4L4oZ2JZckS0VzhxaZoVLckF7VXE+NIYXILsJ8f1I="); - var expectedPlaintext = Convert.FromBase64String("dmF1bHQgcGF5bG9hZCBleGFtcGxlCmxpbmUy"); - var decrypted = crypto.DecryptData(payload, "vault-pass-123"); - - Assert(decrypted.SequenceEqual(expectedPlaintext), "C# decrypt should match Python-generated payload plaintext."); - - return Task.CompletedTask; -} - -static Task TestVaultKeyDerivationMatchesPythonAsync() -{ - var crypto = new VaultCryptoService(); - var salt = Enumerable.Range(0, VaultCryptoService.SaltSize).Select(i => (byte)i).ToArray(); - var key = crypto.DeriveKey("vault-pass-123", salt); - var expectedKeyHex = "b29f523f28bf178f6815c6ca9ee2a588d79b3bd9a822c92a2f0dde5bc853bb52"; - var actualKeyHex = Convert.ToHexString(key).ToLowerInvariant(); - - Assert(actualKeyHex == expectedKeyHex, "Derived key should match Python PBKDF2 fixture key."); - - return Task.CompletedTask; -} - -static Task TestVaultMonthlyFilenameParityAsync() -{ - 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 vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - File.WriteAllText(Path.Combine(dataDir, "old_file.md"), "stale"); - - var zipBytes = CreateZipBytes(new Dictionary - { - ["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); - var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); - - 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 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestVaultLoadWrongPasswordPreservesVaultAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - var zipBytes = CreateZipBytes(new Dictionary - { - ["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); - - 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."); - Assert(before.SequenceEqual(after), "Vault file bytes should remain unchanged on wrong password."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestVaultLoadLegacyInitVaultHandlingAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - var legacyPath = Path.Combine(vaultDir, "_init_vault.vault"); - File.WriteAllBytes(legacyPath, [1, 2, 3, 4]); - - 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 removed during load."); - Assert(Directory.Exists(dataDir), "Data directory should exist after load workflow."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestVaultCurrentMonthSaveOptimizedAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - 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"); - - IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); - var now = new DateTime(2026, 2, 22, 12, 0, 0, DateTimeKind.Utc); - - var firstSaved = storage.SaveCurrentMonthVault("vault-pass-123", vaultDir, dataDir, now); - Assert(firstSaved, "Expected first current-month save to write vault data."); - - 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestVaultRebuildAllVaultsAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - 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"); - - IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); - storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); - - 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestVaultClearDataDirectoryAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(dataDir); - Directory.CreateDirectory(Path.Combine(dataDir, "nested")); - - try - { - 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"); - - IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); - storage.ClearDataDirectory(dataDir); - - Assert(Directory.Exists(dataDir), "Data directory should be recreated after cleanup."); - Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after cleanup."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Dictionary ReadVaultEntryTexts(string vaultPath, string password) -{ - var crypto = new VaultCryptoService(); - var encrypted = File.ReadAllBytes(vaultPath); - var zipBytes = crypto.DecryptData(encrypted, password); - - using var stream = new MemoryStream(zipBytes); - using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var result = new Dictionary(StringComparer.Ordinal); - foreach (var entry in archive.Entries) - { - if (string.IsNullOrEmpty(entry.Name)) - continue; - - using var reader = new StreamReader(entry.Open()); - result[entry.Name] = reader.ReadToEnd(); - } - - return result; -} - -static byte[] CreateZipBytes(Dictionary files) -{ - using var stream = new MemoryStream(); - using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var (name, content) in files) - { - var entry = archive.CreateEntry(name); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - } - return stream.ToArray(); -} - -static Task TestParserExtractsBoldDateAsync() -{ - var content = """ - --- - type: journal - --- - **Date:** 2026-02-22 - ## Summary - hello - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - Assert(entry.Date == "2026-02-22", "Parser should read date from **Date:** marker."); - return Task.CompletedTask; -} - -static Task TestParserExtractsPlainDateAsync() -{ - var content = """ - Date: 2026-02-23 - ## Summary - hello - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - Assert(entry.Date == "2026-02-23", "Parser should read date from Date: marker."); - return Task.CompletedTask; -} - -static Task TestParserFallsBackToFileStemAsync() -{ - var content = """ - ## Summary - no explicit date - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-24"); - Assert(entry.Date == "2026-02-24", "Parser should fall back to file stem when no date marker is present."); - return Task.CompletedTask; -} - -static Task TestParserCapturesSectionsAsync() -{ - var content = """ - Date: 2026-02-25 - ## Summary - line one - line two - ### Events / Triggers - Work - trigger line - ## reflection notes - anchor line - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - - Assert(entry.Sections.ContainsKey("Summary"), "Parser should capture Summary section."); - Assert(entry.Sections.ContainsKey("Events / Triggers"), "Parser should capture Events / Triggers section."); - Assert(entry.Sections.ContainsKey("Reflection"), "Parser should match canonical section title by substring."); - Assert(entry.GetSection("Summary").Contains("line one"), "Summary section content mismatch."); - Assert(entry.GetSection("Events / Triggers").Contains("trigger line"), "Events / Triggers section content mismatch."); - Assert(entry.GetSection("Reflection").Contains("anchor line"), "Reflection section content mismatch."); - - return Task.CompletedTask; -} - -static Task TestParserIgnoresNonCanonicalHeadersAsync() -{ - var content = """ - ## Summary - keep this - ## Totally Custom Header - should not be captured - ### Events / Triggers - keep this too - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - - Assert(entry.GetSection("Summary").Contains("keep this"), "Summary section should be captured."); - Assert(!entry.GetSection("Summary").Contains("should not be captured"), "Non-canonical section content should not bleed into previous section."); - Assert(entry.GetSection("Events / Triggers").Contains("keep this too"), "Canonical section after custom header should be captured."); - - return Task.CompletedTask; -} - -static Task TestParserCapturesCheckboxStatesAsync() -{ - var content = """ - ## Summary - - [x] took medication - - [ ] drank water - * [X] wrote reflection - ## Events / Triggers - - [ ] talked to manager - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - - Assert(entry.GetCheckboxState("Summary", "took medication") is true, "Expected checked state for '- [x]' checkbox."); - Assert(entry.GetCheckboxState("Summary", "drank water") is false, "Expected unchecked state for '- [ ]' checkbox."); - Assert(entry.GetCheckboxState("Summary", "wrote reflection") is true, "Expected checked state for '* [X]' checkbox."); - Assert(entry.GetCheckboxState("Events / Triggers", "talked to manager") is false, "Expected unchecked state in Events / Triggers section."); - Assert(entry.GetCheckboxState("Summary", "missing item") is null, "Missing checkbox text should return null."); - - return Task.CompletedTask; -} - -static Task TestParserCapturesMultilineFragmentsAsync() -{ - var content = """ - Date: 2026-02-26 - ## Summary - text - !TRIGGER @2026-02-26T10:15:00Z #stress #body - first line - second line - !NOTE #daily - short note - """; - - var entry = JournalParser.ParseJournalContent(content, "2026-02-01"); - - Assert(entry.Fragments.Count == 2, "Expected two parsed fragments."); - Assert(entry.Fragments[0].Type == "!TRIGGER", "First fragment type mismatch."); - Assert(entry.Fragments[0].Description == "first line\nsecond line", "First fragment multiline description mismatch."); - Assert(entry.Fragments[0].Tags.Count == 2, "First fragment tag count mismatch."); - Assert(entry.Fragments[0].Tags[0] == "stress" && entry.Fragments[0].Tags[1] == "body", "First fragment tags mismatch."); - Assert(entry.Fragments[1].Type == "!NOTE", "Second fragment type mismatch."); - Assert(entry.Fragments[1].Description == "short note", "Second fragment description mismatch."); - Assert(entry.Fragments[1].Tags.Count == 1 && entry.Fragments[1].Tags[0] == "daily", "Second fragment tags mismatch."); - - return Task.CompletedTask; -} - -static Task TestParserFragmentBoundaryBehaviorAsync() -{ - var content = """ - !TRIGGER #a - line one - !NOTE this starts another fragment header - line two - """; - - var fragments = JournalParser.ParseFragments(content); - Assert(fragments.Count == 1, "Expected one parsed fragment because second boundary line is not a valid fragment header."); - Assert(fragments[0].Description == "line one", "First fragment boundary capture mismatch."); - return Task.CompletedTask; -} - -static async Task TestEntryUnknownActionAsync() -{ - var entry = NewEntry(); - var response = await entry.HandleCommandAsync("""{"action":"unknown.action"}"""); - using var doc = JsonDocument.Parse(response); - - Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); - Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Unknown action"), "Expected unknown action error."); -} - -static async Task TestEntryInvalidJsonAsync() -{ - var entry = NewEntry(); - var response = await entry.HandleCommandAsync("{\"action\":\"fragments.list\""); - using var doc = JsonDocument.Parse(response); - - Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); - Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid command JSON"), "Expected invalid JSON error."); -} - -static async Task TestEntryGetMissingReturnsNullDataAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "fragments.get", - id = Guid.NewGuid().ToString(), - }); - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true."); - Assert(doc.RootElement.GetProperty("data").ValueKind == JsonValueKind.Null, "Expected data=null for missing fragment."); -} - -static async Task TestEntryCreateMissingPayloadAsync() -{ - var entry = NewEntry(); - var response = await entry.HandleCommandAsync("""{"action":"fragments.create"}"""); - using var doc = JsonDocument.Parse(response); - - Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false."); - Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("payload", StringComparison.OrdinalIgnoreCase), "Expected payload validation error."); -} - -static async Task TestEntryEntriesSaveMergeAsync() -{ - 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 entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "entries.save", - payload = new + try { - filePath, - mode = "Daily", - content = """ -Date: 2026-02-22 -## Summary -new summary text -## Reflection -new reflection text -""" + await run(); + Console.WriteLine($"PASS {name}"); + passed++; } - }); - - 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 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 + catch (Exception ex) { - filePath, - mode = "Fragment", - content = "!NOTE\nfragment append text" + Console.WriteLine($"FAIL {name}: {ex.Message}"); } - }); - - 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 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 -"""; - File.WriteAllText(filePath, content); - - var entry = NewEntry(); - 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 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 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 request = JsonSerializer.Serialize(new - { - action = "entries.list", - payload = new - { - dataDirectory = root - } - }); - - 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 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 request = JsonSerializer.Serialize(new - { - action = "search.entries", - payload = new - { - dataDirectory = root, - query = "common token", - } - }); - - 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."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -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 request = JsonSerializer.Serialize(new - { - action = "search.entries", - payload = new - { - dataDirectory = root, - } - }); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - WriteSearchFixtureFiles(root); - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - 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); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - WriteSearchFixtureFiles(root); - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "search.entries", - payload = new - { - dataDirectory = root, - query = "focus area", - section = "Reflection", - } - }); - - 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."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -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 request = JsonSerializer.Serialize(new - { - 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); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - WriteSearchFixtureFiles(root); - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - 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); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - WriteSearchFixtureFiles(root); - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "search.entries", - payload = new - { - dataDirectory = root, - startDate = "2026/02/01", - } - }); - - 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."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static void WriteSearchFixtureFiles(string root) -{ - File.WriteAllText(Path.Combine(root, "2026-02-01.md"), """ -Date: 2026-02-01 -## Summary -Alpha common -## Reflection -focus area -- [x] med taken -!TRIGGER #stress -fragment one -"""); - - File.WriteAllText(Path.Combine(root, "2026-02-05.md"), """ -Date: 2026-02-05 -## Summary -Beta common -## Reflection -other notes -- [ ] drink water -!NOTE #daily -fragment two -"""); - - File.WriteAllText(Path.Combine(root, "2026-03-01.md"), """ -Date: 2026-03-01 -## Summary -Gamma unique -## Reflection -nothing related -!NOTE #other -fragment three -"""); -} - -static Task TestDatabaseKeyDerivationMatchesPythonAsync() -{ - var service = NewDatabaseService(); - var keyHex = Convert.ToHexString(service.DeriveDatabaseKey("vault-pass-123")).ToLowerInvariant(); - var expected = "6a9de08e13357aa8f14e7eb0ccde119e7b4d277c60aaaca6493d9a1e1eaa5b04"; - Assert(keyHex == expected, "Database key derivation should match Python PBKDF2 fixture."); - return Task.CompletedTask; -} - -static Task TestDatabaseSchemaParityAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - var service = NewDatabaseService(); - var schemaPath = service.WriteSchemaBootstrap(root); - var statements = service.GetSchemaStatements(); - var tableNames = new HashSet(statements.Keys, StringComparer.OrdinalIgnoreCase); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - 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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - 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("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 root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N")); - 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 - { - 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"); - - 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 = 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(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."); - Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch."); - Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch."); - Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch."); - Assert(current.AiProvider == "none", "Config AiProvider default mismatch."); - Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch."); - Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch."); - Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch."); - - return Task.CompletedTask; -} - -static async Task TestEntryConfigGetAsync() -{ - var entry = NewEntry(); - var response = await entry.HandleCommandAsync("""{"action":"config.get"}"""); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get."); - var data = doc.RootElement.GetProperty("data"); - Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get."); - - 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."); -} - -static Task TestLogRedactorScrubsSensitiveFieldsAsync() -{ - var payload = JsonSerializer.SerializeToElement(new - { - password = "vault-pass-123", - content = "private journal body", - prompt = "private ai prompt", - nested = new - { - token = "abc123" - } - }); - - var redacted = LogRedactor.RedactPayload(payload); - var serialized = JsonSerializer.Serialize(redacted); - - Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted."); - Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted."); - Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted."); - Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted."); - Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present."); - - return Task.CompletedTask; -} - -static Task TestLogRedactorPreservesNonSensitiveFieldsAsync() -{ - var payload = JsonSerializer.SerializeToElement(new - { - action = "entries.save", - mode = "Daily", - filePath = "E:/journal/2026-02-24.md" - }); - - var redacted = LogRedactor.RedactPayload(payload); - var serialized = JsonSerializer.Serialize(redacted); - - Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved."); - Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved."); - Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved."); - - return Task.CompletedTask; -} - -static async Task TestEntryAiHealthDefaultAsync() -{ - var entry = NewEntry(); - var response = await entry.HandleCommandAsync("""{"action":"ai.health"}"""); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health."); - var data = doc.RootElement.GetProperty("data"); - Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default."); - Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'."); -} - -static async Task TestEntryAiSummarizeEntryDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "ai.summarize_entry", - payload = new - { - content = "sample entry" - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry."); - var data = doc.RootElement.GetProperty("data").GetString() ?? ""; - Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry."); -} - -static async Task TestEntryAiSummarizeAllDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "ai.summarize_all", - payload = new - { - entries = new[] { "entry one", "entry two" } - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all."); - var data = doc.RootElement.GetProperty("data").GetString() ?? ""; - Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all."); -} - -static async Task TestEntryAiChatDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "ai.chat", - payload = new - { - prompt = "hello cloud" - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat."); - var data = doc.RootElement.GetProperty("data").GetString() ?? ""; - Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat."); -} - -static async Task TestEntryAiEmbedDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "ai.embed", - payload = new - { - content = "embedding source text" - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed."); - var data = doc.RootElement.GetProperty("data"); - Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array."); - Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector."); -} - -static async Task TestEntrySpeechDevicesListDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "speech.devices.list", - payload = new { } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled."); - var data = doc.RootElement.GetProperty("data"); - Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object."); -} - -static async Task TestEntrySpeechTranscribeDisabledAsync() -{ - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "speech.transcribe", - payload = new - { - text = "fixture transcript", - engine = "whisper" - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled."); - var data = doc.RootElement.GetProperty("data"); - Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object."); - var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : ""; - Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning."); -} - -static async Task TestPythonSidecarAiServiceJsonLineAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var scriptPath = Path.Combine(root, "fake_ai_sidecar.py"); - File.WriteAllText(scriptPath, """ -import json, sys -request = json.loads(sys.stdin.readline()) -action = request.get("action", "") -print("DEBUG prelude") -if action == "health": - print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}})) -elif action == "summarize_entry": - payload = request.get("payload") or {} - print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))})) -elif action == "summarize_all": - payload = request.get("payload") or {} - entries = payload.get("entries") or [] - print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))})) -elif action == "chat": - payload = request.get("payload") or {} - print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))})) -elif action == "embed": - payload = request.get("payload") or {} - text = str(payload.get("content", "")) - print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]})) -else: - print(json.dumps({"ok": False, "error": "unknown action"})) -"""); - - try - { - var config = BuildAiConfig(scriptPath, timeoutMs: 4000); - IAiService service = new PythonSidecarAiService(config); - - var health = await service.HealthAsync(); - Assert(health.Enabled, "Expected enabled=true for python-sidecar health."); - Assert(health.Healthy, "Expected healthy=true from fake sidecar health."); - - var one = await service.SummarizeEntryAsync("hello"); - Assert(one == "ENTRY::hello", "Unexpected summarize_entry response."); - - var all = await service.SummarizeAllAsync(["a", "b", "c"]); - Assert(all == "ALL::3", "Unexpected summarize_all response."); - - var chat = await service.ChatAsync("hello"); - Assert(chat == "CHAT::hello", "Unexpected chat response."); - - var vector = await service.EmbedAsync("hello"); - Assert(vector.Count == 3, "Unexpected embed vector length."); - Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value."); - Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value."); - Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static async Task TestPythonSidecarAiServiceErrorAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py"); - File.WriteAllText(scriptPath, """ -import json, sys -_ = json.loads(sys.stdin.readline()) -print(json.dumps({"ok": False, "error": "simulated failure"})) -"""); - - try - { - var config = BuildAiConfig(scriptPath, timeoutMs: 4000); - IAiService service = new PythonSidecarAiService(config); - - try - { - _ = await service.SummarizeEntryAsync("hello"); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase)) - { - return; } - throw new InvalidOperationException("Expected summarize_entry to surface sidecar error."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + Console.WriteLine($"Summary: {passed}/{tests.Count} passed."); + return passed == tests.Count ? 0 : 1; } } - -static async Task TestPythonSidecarSpeechServiceNoDevicesAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var scriptPath = Path.Combine(root, "fake_speech_sidecar_nodes.py"); - File.WriteAllText(scriptPath, """ -import json, sys -request = json.loads(sys.stdin.readline()) -action = request.get("action", "") -if action == "speech.devices.list": - print(json.dumps({"ok": True, "data": {"devices": [], "warning": "no devices"}})) -elif action == "speech.transcribe": - payload = request.get("payload") or {} - print(json.dumps({"ok": True, "data": {"text": payload.get("text", ""), "engine": payload.get("engine", "whisper")}})) -else: - print(json.dumps({"ok": False, "error": "unknown action"})) -"""); - - try - { - var config = BuildAiConfig(scriptPath, timeoutMs: 4000); - ISpeechBridgeService service = new PythonSidecarSpeechService(config); - - var devices = await service.ListDevicesAsync(); - Assert(devices.Devices.Count == 0, "Expected empty devices list."); - Assert((devices.Warning ?? "").Contains("no devices", StringComparison.OrdinalIgnoreCase), "Expected no-devices warning."); - - var transcript = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture text", Engine: "whisper")); - Assert(transcript.Text == "fixture text", "Expected passthrough transcript text."); - Assert(transcript.Engine == "whisper", "Expected passthrough transcript engine."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static async Task TestPythonSidecarSpeechServiceErrorAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var scriptPath = Path.Combine(root, "fake_speech_sidecar_error.py"); - File.WriteAllText(scriptPath, """ -import json, sys -request = json.loads(sys.stdin.readline()) -action = request.get("action", "") -if action == "speech.transcribe": - print(json.dumps({"ok": False, "error": "engine unavailable"})) -else: - print(json.dumps({"ok": True, "data": {"devices": []}})) -"""); - - try - { - var config = BuildAiConfig(scriptPath, timeoutMs: 4000); - ISpeechBridgeService service = new PythonSidecarSpeechService(config); - - try - { - _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", Engine: "faster-whisper")); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("engine unavailable", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - throw new InvalidOperationException("Expected speech transcribe to surface sidecar engine error."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static async Task TestPythonSidecarSpeechServiceTimeoutAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var scriptPath = Path.Combine(root, "fake_speech_sidecar_timeout.py"); - File.WriteAllText(scriptPath, """ -import json, sys, time -request = json.loads(sys.stdin.readline()) -payload = request.get("payload") or {} -sleep_ms = int(payload.get("simulate_delay_ms") or 0) -time.sleep(max(0, sleep_ms) / 1000.0) -print(json.dumps({"ok": True, "data": {"text": "", "engine": "whisper"}})) -"""); - - try - { - var config = BuildAiConfig(scriptPath, timeoutMs: 100); - ISpeechBridgeService service = new PythonSidecarSpeechService(config); - - try - { - _ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", SimulateDelayMs: 500)); - } - catch (TimeoutException) - { - return; - } - - throw new InvalidOperationException("Expected speech transcribe timeout path."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static async Task TestEntryVaultLoadAllEmptyAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - - try - { - var entry = NewEntry(); - var request = JsonSerializer.Serialize(new - { - action = "vault.load_all", - payload = new - { - password = "vault-pass-123", - vaultDirectory = vaultDir, - dataDirectory = dataDir, - } - }); - - 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -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 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); - - 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 vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - - 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, "--data-dir", dataDir]); - - 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestSidecarVaultCliSaveAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - File.WriteAllText(Path.Combine(dataDir, "2026-02-22.md"), "entry body"); - - 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, "2026-02.vault")), "Expected monthly vault file to be written by save CLI command."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestSidecarSearchCliFilteredAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(dataDir); - - try - { - WriteSearchFixtureFiles(dataDir); - var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService()); - - var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand( - [ - "common", - "--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."); - Assert(string.IsNullOrWhiteSpace(stderr), "Expected no stderr output for successful search CLI command."); - Assert(stdout.Contains("--- 2026-02-01 ---", StringComparison.Ordinal), "Expected matching entry header in search CLI output."); - Assert(!stdout.Contains("--- 2026-02-05 ---", StringComparison.Ordinal), "Unexpected non-matching entry in filtered search CLI output."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static Task TestSidecarSearchCliEmptyDataAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N")); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(dataDir); - - try - { - 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 directory."); - Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) -{ - var originalOut = Console.Out; - var originalError = Console.Error; - using var stdout = new StringWriter(); - using var stderr = new StringWriter(); - - try - { - Console.SetOut(stdout); - Console.SetError(stderr); - var exitCode = action(); - return (exitCode, stdout.ToString(), stderr.ToString()); - } - finally - { - Console.SetOut(originalOut); - Console.SetError(originalError); - } -} - -static JournalConfig BuildAiConfig(string sidecarScriptPath, int timeoutMs) -{ - var baseConfig = new JournalConfigService().Current; - var pythonExe = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE"); - if (string.IsNullOrWhiteSpace(pythonExe)) - pythonExe = "python"; - - return baseConfig with - { - AiProvider = "python-sidecar", - PythonExecutable = pythonExe, - PythonAiSidecarPath = sidecarScriptPath, - AiSidecarTimeoutMs = timeoutMs - }; -} - -static async Task TestTransportFixturesAsync() -{ - var fixtures = await LoadTransportFixturesAsync(); - Assert(fixtures.Count > 0, "Transport fixtures should not be empty."); - - foreach (var fixture in fixtures) - { - var entry = NewEntry(); - var response = await entry.HandleCommandAsync(fixture.Request); - - Assert(!response.Contains('\n') && !response.Contains('\r'), $"Fixture '{fixture.Name}' returned multiline output."); - - using var doc = JsonDocument.Parse(response); - var ok = doc.RootElement.GetProperty("ok").GetBoolean(); - Assert(ok == fixture.ExpectOk, $"Fixture '{fixture.Name}' expected ok={fixture.ExpectOk} but got ok={ok}."); - - if (fixture.ExpectOk) - { - Assert(doc.RootElement.TryGetProperty("data", out var data), $"Fixture '{fixture.Name}' expected data field."); - if (!string.IsNullOrWhiteSpace(fixture.DataKind)) - { - var expectedKind = ParseValueKind(fixture.DataKind!); - Assert(data.ValueKind == expectedKind, $"Fixture '{fixture.Name}' expected data kind {expectedKind} but got {data.ValueKind}."); - } - continue; - } - - Assert(doc.RootElement.TryGetProperty("error", out var error), $"Fixture '{fixture.Name}' expected error field."); - if (!string.IsNullOrWhiteSpace(fixture.ErrorContains)) - { - var message = error.GetString() ?? ""; - Assert(message.Contains(fixture.ErrorContains!, StringComparison.OrdinalIgnoreCase), $"Fixture '{fixture.Name}' expected error containing '{fixture.ErrorContains}'."); - } - } -} - -static async Task> LoadTransportFixturesAsync() -{ - var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "transport_cases.json"); - if (!File.Exists(path)) - throw new FileNotFoundException($"Transport fixture file not found: {path}"); - - var json = await File.ReadAllTextAsync(path); - return JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }) ?? []; -} - -static JsonValueKind ParseValueKind(string value) => value.Trim().ToLowerInvariant() switch -{ - "array" => JsonValueKind.Array, - "object" => JsonValueKind.Object, - "null" => JsonValueKind.Null, - "string" => JsonValueKind.String, - "number" => JsonValueKind.Number, - "true" => JsonValueKind.True, - "false" => JsonValueKind.False, - _ => throw new InvalidOperationException($"Unsupported JsonValueKind '{value}' in transport fixture.") -}; - -static void Assert(bool condition, string message) -{ - if (!condition) - throw new InvalidOperationException(message); -} - -static Task TestEntrySavePayloadFileNameDeserializationAsync() -{ - // Simulate what DeserializePayload does: parse JSON to JsonElement, then Deserialize - var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}"""; - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var element = JsonSerializer.Deserialize(json); - var payload = element.Deserialize(options); - - Assert(payload is not null, "Payload should not be null."); - Assert(payload!.Content == "hello", "Content should be deserialized."); - Assert(payload.Mode == "Overwrite", "Mode should be deserialized."); - Assert(payload.FileName == "My Custom Name", "FileName should be deserialized from camelCase JSON via JsonElement."); - Assert(payload.FilePath is null, "FilePath should be null when not provided."); - - return Task.CompletedTask; -} - -static async Task TestEntrySaveWithFileNameAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N")); - 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, root); - - 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 - { - action = "entries.save", - payload = new - { - content = "# Second Entry", - mode = "Overwrite", - fileName = "Another Custom Name" - } - }); - - var response = await entry.HandleCommandAsync(request); - using var doc = JsonDocument.Parse(response); - 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 Custom Name.md", StringComparison.OrdinalIgnoreCase), - $"Expected file path to contain 'Another Custom Name.md' but got '{savedFilePath}'."); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} - -static Task TestVaultCustomEntryRoundtripAsync() -{ - var root = Path.Combine(Path.GetTempPath(), "journal-vault-smoke", Guid.NewGuid().ToString("N")); - var vaultDir = Path.Combine(root, "vault"); - var dataDir = Path.Combine(root, "data"); - Directory.CreateDirectory(vaultDir); - Directory.CreateDirectory(dataDir); - - try - { - // 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"); - - // Rebuild vaults (simulates app close) - IVaultStorageService storage = new VaultStorageService(new VaultCryptoService()); - storage.RebuildAllVaults("vault-pass-123", vaultDir, dataDir); - - // 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."); - - // Clear data directory (simulates app close step 2) - storage.ClearDataDirectory(dataDir); - Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Data directory should be empty after clear."); - - // Load vaults (simulates app restart) - var ok = storage.LoadAllVaults("vault-pass-123", vaultDir, dataDir); - Assert(ok, "Expected vault load to succeed."); - - // 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 - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - - return Task.CompletedTask; -} - -sealed class TransportFixture -{ - public string Name { get; init; } = ""; - public string Request { get; init; } = ""; - public bool ExpectOk { get; init; } - public string? DataKind { get; init; } - public string? ErrorContains { get; init; } -}