refactor(smoke-tests): split Program.cs into grouped partial files
This commit is contained in:
parent
d1e4989303
commit
1e28ae9e25
19
Journal.SmokeTests/GlobalUsings.cs
Normal file
19
Journal.SmokeTests/GlobalUsings.cs
Normal file
@ -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;
|
||||
342
Journal.SmokeTests/Program.AiSpeechTests.cs
Normal file
342
Journal.SmokeTests/Program.AiSpeechTests.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
Journal.SmokeTests/Program.DatabaseConfigTests.cs
Normal file
249
Journal.SmokeTests/Program.DatabaseConfigTests.cs
Normal file
@ -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<string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
565
Journal.SmokeTests/Program.EntryTests.cs
Normal file
565
Journal.SmokeTests/Program.EntryTests.cs
Normal file
@ -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<T>
|
||||
var json = """{"content":"hello","mode":"Overwrite","fileName":"My Custom Name"}""";
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var element = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
var payload = element.Deserialize<EntrySavePayload>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
232
Journal.SmokeTests/Program.FragmentTests.cs
Normal file
232
Journal.SmokeTests/Program.FragmentTests.cs
Normal file
@ -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<string, bool> { ["completed thing"] = true });
|
||||
|
||||
var entry = new JournalEntry(
|
||||
date: "2026-02-22",
|
||||
fragments: [fragment],
|
||||
rawContent: "raw markdown content",
|
||||
sections: new Dictionary<string, ParsedSection> { ["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<string, ParsedSection>
|
||||
{
|
||||
["Summary"] = new ParsedSection("Summary", ["old content"])
|
||||
});
|
||||
|
||||
var incoming = new JournalEntry(
|
||||
date: "2026-02-22",
|
||||
sections: new Dictionary<string, ParsedSection>
|
||||
{
|
||||
["Summary"] = new ParsedSection(
|
||||
"Summary",
|
||||
[" ", "new content line"],
|
||||
new Dictionary<string, bool> { ["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<string, ParsedSection>
|
||||
{
|
||||
["Summary"] = new ParsedSection("Summary", ["keep existing"])
|
||||
});
|
||||
|
||||
var incoming = new JournalEntry(
|
||||
date: "2026-02-22",
|
||||
sections: new Dictionary<string, ParsedSection>
|
||||
{
|
||||
["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<string, ParsedSection>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
}
|
||||
|
||||
153
Journal.SmokeTests/Program.ParserTests.cs
Normal file
153
Journal.SmokeTests/Program.ParserTests.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
169
Journal.SmokeTests/Program.Shared.cs
Normal file
169
Journal.SmokeTests/Program.Shared.cs
Normal file
@ -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<string, string> 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<string, string>(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<string, string> 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<int> 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<List<TransportFixture>> 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<List<TransportFixture>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
48
Journal.SmokeTests/Program.TransportTests.cs
Normal file
48
Journal.SmokeTests/Program.TransportTests.cs
Normal file
@ -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; }
|
||||
}
|
||||
|
||||
436
Journal.SmokeTests/Program.VaultTests.cs
Normal file
436
Journal.SmokeTests/Program.VaultTests.cs
Normal file
@ -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<string, string>
|
||||
{
|
||||
["2026-02-01.md"] = "hello from vault"
|
||||
});
|
||||
var crypto = new VaultCryptoService();
|
||||
var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123");
|
||||
File.WriteAllBytes(Path.Combine(vaultDir, "2026-02.vault"), encrypted);
|
||||
|
||||
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<string, string>
|
||||
{
|
||||
["2026-02-01.md"] = "hello from vault"
|
||||
});
|
||||
var crypto = new VaultCryptoService();
|
||||
var encrypted = crypto.EncryptData(zipBytes, "vault-pass-123");
|
||||
var vaultPath = Path.Combine(vaultDir, "2026-02.vault");
|
||||
File.WriteAllBytes(vaultPath, encrypted);
|
||||
var before = File.ReadAllBytes(vaultPath);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user