2196 lines
88 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Journal.Core;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Repositories;
using Journal.Core.Services;
var tests = new List<(string Name, Func<Task> Run)>
{
("CreateAsync trims fields", TestCreateTrimsAsync),
("UpdateAsync accepts valid type updates", TestUpdateAcceptsTypeAsync),
("UpdateAsync rejects whitespace type", TestUpdateRejectsWhitespaceTypeAsync),
("JournalEntry model stores parity fields", TestJournalEntryModelAsync),
("MergeWith overwrites section when new content is meaningful", TestMergeOverwritesMeaningfulSectionAsync),
("MergeWith ignores whitespace-only section updates", TestMergeIgnoresWhitespaceOnlySectionAsync),
("MergeWith appends non-duplicate fragments by description", TestMergeAppendsNonDuplicateFragmentsAsync),
("ToMarkdown writes canonical section order", TestToMarkdownCanonicalSectionOrderAsync),
("ToMarkdown writes fragment blocks", TestToMarkdownFragmentFormattingAsync),
("Vault crypto roundtrip preserves data and layout", TestVaultCryptoRoundtripAsync),
("Vault crypto decrypts Python payload fixture", TestVaultCryptoDecryptsPythonFixtureAsync),
("Vault key derivation matches Python fixture", TestVaultKeyDerivationMatchesPythonAsync),
("Vault monthly filename matches parity format", TestVaultMonthlyFilenameParityAsync),
("Vault load clears workspace and extracts decrypted files", TestVaultLoadClearsAndExtractsAsync),
("Vault load wrong password does not modify vault files", TestVaultLoadWrongPasswordPreservesVaultAsync),
("Vault load ignores and removes legacy _init_vault.vault", TestVaultLoadLegacyInitVaultHandlingAsync),
("Vault current-month save writes only current month and skips unchanged state", TestVaultCurrentMonthSaveOptimizedAsync),
("Vault rebuild saves grouped monthly archives from decrypted files", TestVaultRebuildAllVaultsAsync),
("Vault clear data directory removes decrypted workspace artifacts", TestVaultClearDataDirectoryAsync),
("Parser extracts date from **Date:** marker", TestParserExtractsBoldDateAsync),
("Parser extracts date from Date: marker", TestParserExtractsPlainDateAsync),
("Parser falls back to file stem when date missing", TestParserFallsBackToFileStemAsync),
("Parser captures canonical sections and content", TestParserCapturesSectionsAsync),
("Parser ignores non-canonical section headers", TestParserIgnoresNonCanonicalHeadersAsync),
("Parser captures checkbox states per section", TestParserCapturesCheckboxStatesAsync),
("Parser captures multiline fragment blocks", TestParserCapturesMultilineFragmentsAsync),
("Parser fragment boundary follows header lines", TestParserFragmentBoundaryBehaviorAsync),
("File repository persists fragments", TestFileRepositoryPersistsAsync),
("Entry invalid JSON returns error envelope", TestEntryInvalidJsonAsync),
("Entry unknown action returns error envelope", TestEntryUnknownActionAsync),
("Entry get missing id returns ok with null data", TestEntryGetMissingReturnsNullDataAsync),
("Entry create without payload returns error envelope", TestEntryCreateMissingPayloadAsync),
("Entry entries.save writes and merges content", TestEntryEntriesSaveMergeAsync),
("Entry entries.load returns raw content payload", TestEntryEntriesLoadAsync),
("Entry entries.list returns markdown files", TestEntryEntriesListAsync),
("Entry search.entries matches query against full raw content", TestEntrySearchEntriesMatchesRawContentAsync),
("Entry search.entries without query returns all markdown entries", TestEntrySearchEntriesWithoutQueryReturnsAllAsync),
("Entry search.entries applies date range filter", TestEntrySearchEntriesDateRangeFilterAsync),
("Entry search.entries applies section-scoped query filter", TestEntrySearchEntriesSectionFilterAsync),
("Entry search.entries applies fragment tag and type filters", TestEntrySearchEntriesTagTypeFilterAsync),
("Entry search.entries applies checkbox checked and unchecked filters", TestEntrySearchEntriesCheckboxFilterAsync),
("Entry search.entries rejects invalid date filter format", TestEntrySearchEntriesRejectsInvalidDateAsync),
("Database key derivation matches Python fixture", TestDatabaseKeyDerivationMatchesPythonAsync),
("Database schema parity tables are created", TestDatabaseSchemaParityAsync),
("Entry db.status returns database compatibility payload", TestEntryDatabaseStatusAsync),
("Entry db.status rejects wrong key for existing encrypted database", TestEntryDatabaseStatusWrongKeyFailsAsync),
("Entry db.initialize_schema creates schema in data directory", TestEntryDatabaseInitializeSchemaAsync),
("Entry db.hydrate_workspace returns hydration metadata", TestEntryDatabaseHydrateWorkspaceAsync),
("Config service exposes parity path, vault, AI, and speech settings", TestConfigServiceParityKeysAsync),
("Entry config.get returns config payload", TestEntryConfigGetAsync),
("Log redactor scrubs sensitive payload fields", TestLogRedactorScrubsSensitiveFieldsAsync),
("Log redactor preserves non-sensitive payload fields", TestLogRedactorPreservesNonSensitiveFieldsAsync),
("Entry ai.health returns disabled by default", TestEntryAiHealthDefaultAsync),
("Entry ai.summarize_entry succeeds when disabled", TestEntryAiSummarizeEntryDisabledAsync),
("Entry ai.summarize_all succeeds when disabled", TestEntryAiSummarizeAllDisabledAsync),
("Entry ai.chat succeeds when disabled", TestEntryAiChatDisabledAsync),
("Entry ai.embed returns empty vector when disabled", TestEntryAiEmbedDisabledAsync),
("Entry speech.devices.list returns envelope when disabled", TestEntrySpeechDevicesListDisabledAsync),
("Entry speech.transcribe returns envelope when disabled", TestEntrySpeechTranscribeDisabledAsync),
("Python sidecar AI service parses last JSON line", TestPythonSidecarAiServiceJsonLineAsync),
("Python sidecar AI service surfaces sidecar errors", TestPythonSidecarAiServiceErrorAsync),
("Python sidecar speech service handles empty devices payload", TestPythonSidecarSpeechServiceNoDevicesAsync),
("Python sidecar speech service surfaces unavailable engine errors", TestPythonSidecarSpeechServiceErrorAsync),
("Python sidecar speech service times out deterministically", TestPythonSidecarSpeechServiceTimeoutAsync),
("Entry vault.load_all succeeds for empty vault directory", TestEntryVaultLoadAllEmptyAsync),
("Entry vault.clear_data_directory removes files", TestEntryVaultClearDataDirectoryAsync),
("Sidecar vault CLI load succeeds with --password", TestSidecarVaultCliLoadAsync),
("Sidecar vault CLI save writes monthly vault with --password", TestSidecarVaultCliSaveAsync),
("Sidecar search CLI returns matching entries with filters", TestSidecarSearchCliFilteredAsync),
("Sidecar search CLI warns when no decrypted entries exist", TestSidecarSearchCliEmptyDataAsync),
("Transport fixtures produce stable envelopes", TestTransportFixturesAsync),
};
var passed = 0;
foreach (var (name, run) in tests)
{
try
{
await run();
Console.WriteLine($"PASS {name}");
passed++;
}
catch (Exception ex)
{
Console.WriteLine($"FAIL {name}: {ex.Message}");
}
}
Console.WriteLine($"Summary: {passed}/{tests.Count} passed.");
Environment.ExitCode = passed == tests.Count ? 0 : 1;
static FragmentService NewService()
{
IFragmentRepository repo = new InMemoryFragmentRepository();
return new FragmentService(repo);
}
static Entry NewEntry() => new(
NewService(),
new EntrySearchService(),
new VaultStorageService(new VaultCryptoService()),
new JournalDatabaseService(new JournalConfigService()),
new JournalConfigService(),
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"));
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
static async Task TestCreateTrimsAsync()
{
var service = NewService();
var created = await service.CreateAsync(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.");
}
static async Task TestUpdateAcceptsTypeAsync()
{
var service = NewService();
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "one"));
var ok = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
Assert(ok, "Expected update to succeed.");
var updated = await service.GetByIdAsync(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.");
}
static async Task TestUpdateRejectsWhitespaceTypeAsync()
{
var service = NewService();
var created = await service.CreateAsync(new CreateFragmentDto("!TRIGGER", "desc"));
try
{
_ = await service.UpdateAsync(created.Id, new UpdateFragmentDto(Type: " "));
}
catch (ValidationException)
{
return;
}
throw new InvalidOperationException("Expected ValidationException for whitespace type update.");
}
static async Task TestFileRepositoryPersistsAsync()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
var storePath = Path.Combine(tempRoot, "fragments.json");
try
{
IFragmentRepository repo1 = new FileFragmentRepository(storePath);
var service1 = new FragmentService(repo1);
var created = await service1.CreateAsync(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
IFragmentRepository repo2 = new FileFragmentRepository(storePath);
var service2 = new FragmentService(repo2);
var loaded = await service2.GetByIdAsync(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);
}
}
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;
}
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 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 Task TestParserExtractsBoldDateAsync()
{
var content = """
---
type: journal
---
**Date:** 2026-02-22
## Summary
hello
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.Date == "2026-02-22", "Parser should read date from **Date:** marker.");
return Task.CompletedTask;
}
static Task TestParserExtractsPlainDateAsync()
{
var content = """
Date: 2026-02-23
## Summary
hello
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.Date == "2026-02-23", "Parser should read date from Date: marker.");
return Task.CompletedTask;
}
static Task TestParserFallsBackToFileStemAsync()
{
var content = """
## Summary
no explicit date
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-24");
Assert(entry.Date == "2026-02-24", "Parser should fall back to file stem when no date marker is present.");
return Task.CompletedTask;
}
static Task TestParserCapturesSectionsAsync()
{
var content = """
Date: 2026-02-25
## Summary
line one
line two
### Events / Triggers - Work
trigger line
## reflection notes
anchor line
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.Sections.ContainsKey("Summary"), "Parser should capture Summary section.");
Assert(entry.Sections.ContainsKey("Events / Triggers"), "Parser should capture Events / Triggers section.");
Assert(entry.Sections.ContainsKey("Reflection"), "Parser should match canonical section title by substring.");
Assert(entry.GetSection("Summary").Contains("line one"), "Summary section content mismatch.");
Assert(entry.GetSection("Events / Triggers").Contains("trigger line"), "Events / Triggers section content mismatch.");
Assert(entry.GetSection("Reflection").Contains("anchor line"), "Reflection section content mismatch.");
return Task.CompletedTask;
}
static Task TestParserIgnoresNonCanonicalHeadersAsync()
{
var content = """
## Summary
keep this
## Totally Custom Header
should not be captured
### Events / Triggers
keep this too
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.GetSection("Summary").Contains("keep this"), "Summary section should be captured.");
Assert(!entry.GetSection("Summary").Contains("should not be captured"), "Non-canonical section content should not bleed into previous section.");
Assert(entry.GetSection("Events / Triggers").Contains("keep this too"), "Canonical section after custom header should be captured.");
return Task.CompletedTask;
}
static Task TestParserCapturesCheckboxStatesAsync()
{
var content = """
## Summary
- [x] took medication
- [ ] drank water
* [X] wrote reflection
## Events / Triggers
- [ ] talked to manager
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.GetCheckboxState("Summary", "took medication") is true, "Expected checked state for '- [x]' checkbox.");
Assert(entry.GetCheckboxState("Summary", "drank water") is false, "Expected unchecked state for '- [ ]' checkbox.");
Assert(entry.GetCheckboxState("Summary", "wrote reflection") is true, "Expected checked state for '* [X]' checkbox.");
Assert(entry.GetCheckboxState("Events / Triggers", "talked to manager") is false, "Expected unchecked state in Events / Triggers section.");
Assert(entry.GetCheckboxState("Summary", "missing item") is null, "Missing checkbox text should return null.");
return Task.CompletedTask;
}
static Task TestParserCapturesMultilineFragmentsAsync()
{
var content = """
Date: 2026-02-26
## Summary
text
!TRIGGER @2026-02-26T10:15:00Z #stress #body
first line
second line
!NOTE #daily
short note
""";
var entry = JournalParser.ParseJournalContent(content, "2026-02-01");
Assert(entry.Fragments.Count == 2, "Expected two parsed fragments.");
Assert(entry.Fragments[0].Type == "!TRIGGER", "First fragment type mismatch.");
Assert(entry.Fragments[0].Description == "first line\nsecond line", "First fragment multiline description mismatch.");
Assert(entry.Fragments[0].Tags.Count == 2, "First fragment tag count mismatch.");
Assert(entry.Fragments[0].Tags[0] == "stress" && entry.Fragments[0].Tags[1] == "body", "First fragment tags mismatch.");
Assert(entry.Fragments[1].Type == "!NOTE", "Second fragment type mismatch.");
Assert(entry.Fragments[1].Description == "short note", "Second fragment description mismatch.");
Assert(entry.Fragments[1].Tags.Count == 1 && entry.Fragments[1].Tags[0] == "daily", "Second fragment tags mismatch.");
return Task.CompletedTask;
}
static Task TestParserFragmentBoundaryBehaviorAsync()
{
var content = """
!TRIGGER #a
line one
!NOTE this starts another fragment header
line two
""";
var fragments = JournalParser.ParseFragments(content);
Assert(fragments.Count == 1, "Expected one parsed fragment because second boundary line is not a valid fragment header.");
Assert(fragments[0].Description == "line one", "First fragment boundary capture mismatch.");
return Task.CompletedTask;
}
static async Task TestEntryUnknownActionAsync()
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync("""{"action":"unknown.action"}""");
using var doc = JsonDocument.Parse(response);
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false.");
Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Unknown action"), "Expected unknown action error.");
}
static async Task TestEntryInvalidJsonAsync()
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync("{\"action\":\"fragments.list\"");
using var doc = JsonDocument.Parse(response);
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false.");
Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("Invalid command JSON"), "Expected invalid JSON error.");
}
static async Task TestEntryGetMissingReturnsNullDataAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "fragments.get",
id = Guid.NewGuid().ToString(),
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true.");
Assert(doc.RootElement.GetProperty("data").ValueKind == JsonValueKind.Null, "Expected data=null for missing fragment.");
}
static async Task TestEntryCreateMissingPayloadAsync()
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync("""{"action":"fragments.create"}""");
using var doc = JsonDocument.Parse(response);
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false.");
Assert(doc.RootElement.GetProperty("error").GetString()!.Contains("payload", StringComparison.OrdinalIgnoreCase), "Expected payload validation error.");
}
static async Task TestEntryEntriesSaveMergeAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var filePath = Path.Combine(root, "2026-02-22.md");
File.WriteAllText(filePath, """
Date: 2026-02-22
## Summary
old summary text
""");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "entries.save",
payload = new
{
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");
Assert(data.GetProperty("Date").GetString() == "2026-02-22", "Expected parsed date from entries.load.");
Assert(data.GetProperty("RawContent").GetString() == content, "Expected raw content from entries.load.");
Assert(data.GetProperty("FileName").GetString() == "2026-02-22.md", "Expected file name from entries.load.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryEntriesListAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-entry-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "c");
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "a");
File.WriteAllText(Path.Combine(root, "ignore.txt"), "x");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "entries.list",
payload = new
{
dataDirectory = root
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for entries.list.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected entries.list data array.");
Assert(data.GetArrayLength() == 2, "Expected entries.list to return only markdown files.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Expected entries.list sort order by file name.");
Assert(data[1].GetProperty("FileName").GetString() == "2026-02-03.md", "Expected entries.list sort order by file name.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesMatchesRawContentAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "## Summary\nAlpha line\ncommon token");
File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "## Summary\nbeta line\nCOMMON token");
File.WriteAllText(Path.Combine(root, "2026-02-03.md"), "## Summary\ngamma only");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
query = "common token",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 2, "Expected two entries matching query across raw content.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesWithoutQueryReturnsAllAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), "one");
File.WriteAllText(Path.Combine(root, "2026-02-02.md"), "two");
File.WriteAllText(Path.Combine(root, "ignore.txt"), "not markdown");
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for search.entries without query.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected data array from search.entries.");
Assert(data.GetArrayLength() == 2, "Expected all markdown files to be returned when query is omitted.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesDateRangeFilterAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
startDate = "2026-02-02",
endDate = "2026-02-28",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for date-range filtered search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 1, "Expected one result for filtered date range.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-05.md", "Date-range result mismatch.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesSectionFilterAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
query = "focus area",
section = "Reflection",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for section-scoped search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 1, "Expected one section-scoped result.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Section filter result mismatch.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesTagTypeFilterAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
tags = new[] { "stress" },
types = new[] { "!TRIGGER" },
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for fragment tag/type filtered search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 1, "Expected one result for fragment tag/type filters.");
Assert(data[0].GetProperty("FileName").GetString() == "2026-02-01.md", "Tag/type filter result mismatch.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesCheckboxFilterAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
@checked = new[] { "med taken" },
@unchecked = new[] { "drink water" },
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for checkbox filtered search.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetArrayLength() == 2, "Expected OR-style checkbox match across checked/unchecked filters.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntrySearchEntriesRejectsInvalidDateAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-search-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteSearchFixtureFiles(root);
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "search.entries",
payload = new
{
dataDirectory = root,
startDate = "2026/02/01",
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(!doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=false for invalid date format.");
var error = doc.RootElement.GetProperty("error").GetString() ?? "";
Assert(error.Contains("invalid startdate value", StringComparison.OrdinalIgnoreCase), "Expected invalid startDate error.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static void WriteSearchFixtureFiles(string root)
{
File.WriteAllText(Path.Combine(root, "2026-02-01.md"), """
Date: 2026-02-01
## Summary
Alpha common
## Reflection
focus area
- [x] med taken
!TRIGGER #stress
fragment one
""");
File.WriteAllText(Path.Combine(root, "2026-02-05.md"), """
Date: 2026-02-05
## Summary
Beta common
## Reflection
other notes
- [ ] drink water
!NOTE #daily
fragment two
""");
File.WriteAllText(Path.Combine(root, "2026-03-01.md"), """
Date: 2026-03-01
## Summary
Gamma unique
## Reflection
nothing related
!NOTE #other
fragment three
""");
}
static Task TestDatabaseKeyDerivationMatchesPythonAsync()
{
var service = NewDatabaseService();
var keyHex = Convert.ToHexString(service.DeriveDatabaseKey("vault-pass-123")).ToLowerInvariant();
var expected = "6a9de08e13357aa8f14e7eb0ccde119e7b4d277c60aaaca6493d9a1e1eaa5b04";
Assert(keyHex == expected, "Database key derivation should match Python PBKDF2 fixture.");
return Task.CompletedTask;
}
static Task TestDatabaseSchemaParityAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var service = NewDatabaseService();
var schemaPath = service.WriteSchemaBootstrap(root);
var statements = service.GetSchemaStatements();
var tableNames = new HashSet<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.");
Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in db.status payload.");
Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version.");
Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in db.status payload.");
Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true for SQLCipher-backed database.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryDatabaseStatusWrongKeyFailsAsync()
{
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");
var entry = NewEntry();
var hydrateRequest = JsonSerializer.Serialize(new
{
action = "db.hydrate_workspace",
payload = new
{
password = "vault-pass-123",
dataDirectory = root
}
});
var hydrateResponse = await entry.HandleCommandAsync(hydrateRequest);
using (var hydrateDoc = JsonDocument.Parse(hydrateResponse))
{
Assert(hydrateDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected initial hydrate to succeed.");
}
var wrongStatusRequest = JsonSerializer.Serialize(new
{
action = "db.status",
payload = new
{
password = "wrong-password",
dataDirectory = root
}
});
var wrongStatusResponse = await entry.HandleCommandAsync(wrongStatusRequest);
using var wrongDoc = JsonDocument.Parse(wrongStatusResponse);
Assert(wrongDoc.RootElement.GetProperty("ok").GetBoolean(), "Expected db.status envelope to remain ok=true.");
var data = wrongDoc.RootElement.GetProperty("data");
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in wrong-key db.status payload.");
Assert(runtimeReady.ValueKind == JsonValueKind.False, "Expected RuntimeReady=false when using wrong key on existing encrypted database.");
}
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.");
Assert(data.TryGetProperty("CipherVersion", out var cipherVersion), "Expected CipherVersion in hydrate payload.");
Assert(cipherVersion.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(cipherVersion.GetString()), "Expected non-empty SQLCipher cipher version in hydrate payload.");
Assert(data.TryGetProperty("EncryptedAtRest", out var encryptedAtRest), "Expected EncryptedAtRest in hydrate payload.");
Assert(encryptedAtRest.ValueKind == JsonValueKind.True, "Expected EncryptedAtRest=true when SQLCipher runtime hydration succeeds.");
var dbPath = data.GetProperty("DatabasePath").GetString() ?? "";
Assert(File.Exists(dbPath), "Expected hydrated database file to exist.");
using var stream = File.OpenRead(dbPath);
var headerBytes = new byte[16];
var read = stream.Read(headerBytes, 0, headerBytes.Length);
var header = read > 0 ? System.Text.Encoding.ASCII.GetString(headerBytes, 0, read) : "";
Assert(!header.StartsWith("SQLite format 3", StringComparison.Ordinal), "Expected SQLCipher database header to be non-plaintext.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static Task TestConfigServiceParityKeysAsync()
{
IJournalConfigService config = new JournalConfigService();
var current = config.Current;
Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.DataDirectory), "Config DataDirectory should not be empty.");
Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty.");
Assert(current.MonthlyVaultFormat == "%Y-%m.vault", "Config MonthlyVaultFormat should match Python format token.");
Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch.");
Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch.");
Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch.");
Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch.");
Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch.");
Assert(current.AiProvider == "none", "Config AiProvider default mismatch.");
Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch.");
Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch.");
Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch.");
return Task.CompletedTask;
}
static async Task TestEntryConfigGetAsync()
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync("""{"action":"config.get"}""");
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get.");
Assert(data.TryGetProperty("DataDirectory", out var dataDirectory), "Expected DataDirectory in config payload.");
Assert(dataDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(dataDirectory.GetString()), "Expected non-empty DataDirectory value.");
Assert(data.TryGetProperty("MonthlyVaultFormat", out var monthlyVaultFormat), "Expected MonthlyVaultFormat in config payload.");
Assert(monthlyVaultFormat.GetString() == "%Y-%m.vault", "Expected Python-compatible MonthlyVaultFormat value.");
Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload.");
Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload.");
}
static Task TestLogRedactorScrubsSensitiveFieldsAsync()
{
var payload = JsonSerializer.SerializeToElement(new
{
password = "vault-pass-123",
content = "private journal body",
prompt = "private ai prompt",
nested = new
{
token = "abc123"
}
});
var redacted = LogRedactor.RedactPayload(payload);
var serialized = JsonSerializer.Serialize(redacted);
Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted.");
Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted.");
Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted.");
Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted.");
Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present.");
return Task.CompletedTask;
}
static Task TestLogRedactorPreservesNonSensitiveFieldsAsync()
{
var payload = JsonSerializer.SerializeToElement(new
{
action = "entries.save",
mode = "Daily",
filePath = "E:/journal/2026-02-24.md"
});
var redacted = LogRedactor.RedactPayload(payload);
var serialized = JsonSerializer.Serialize(redacted);
Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved.");
Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved.");
Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved.");
return Task.CompletedTask;
}
static async Task TestEntryAiHealthDefaultAsync()
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync("""{"action":"ai.health"}""");
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health.");
var data = doc.RootElement.GetProperty("data");
Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default.");
Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'.");
}
static async Task TestEntryAiSummarizeEntryDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "ai.summarize_entry",
payload = new
{
content = "sample entry"
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry.");
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry.");
}
static async Task TestEntryAiSummarizeAllDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "ai.summarize_all",
payload = new
{
entries = new[] { "entry one", "entry two" }
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all.");
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all.");
}
static async Task TestEntryAiChatDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "ai.chat",
payload = new
{
prompt = "hello cloud"
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat.");
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat.");
}
static async Task TestEntryAiEmbedDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "ai.embed",
payload = new
{
content = "embedding source text"
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array.");
Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector.");
}
static async Task TestEntrySpeechDevicesListDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "speech.devices.list",
payload = new { }
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object.");
}
static async Task TestEntrySpeechTranscribeDisabledAsync()
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "speech.transcribe",
payload = new
{
text = "fixture transcript",
engine = "whisper"
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled.");
var data = doc.RootElement.GetProperty("data");
Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object.");
var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : "";
Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning.");
}
static async Task TestPythonSidecarAiServiceJsonLineAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var scriptPath = Path.Combine(root, "fake_ai_sidecar.py");
File.WriteAllText(scriptPath, """
import json, sys
request = json.loads(sys.stdin.readline())
action = request.get("action", "")
print("DEBUG prelude")
if action == "health":
print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}}))
elif action == "summarize_entry":
payload = request.get("payload") or {}
print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))}))
elif action == "summarize_all":
payload = request.get("payload") or {}
entries = payload.get("entries") or []
print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))}))
elif action == "chat":
payload = request.get("payload") or {}
print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))}))
elif action == "embed":
payload = request.get("payload") or {}
text = str(payload.get("content", ""))
print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]}))
else:
print(json.dumps({"ok": False, "error": "unknown action"}))
""");
try
{
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
IAiService service = new PythonSidecarAiService(config);
var health = await service.HealthAsync();
Assert(health.Enabled, "Expected enabled=true for python-sidecar health.");
Assert(health.Healthy, "Expected healthy=true from fake sidecar health.");
var one = await service.SummarizeEntryAsync("hello");
Assert(one == "ENTRY::hello", "Unexpected summarize_entry response.");
var all = await service.SummarizeAllAsync(["a", "b", "c"]);
Assert(all == "ALL::3", "Unexpected summarize_all response.");
var chat = await service.ChatAsync("hello");
Assert(chat == "CHAT::hello", "Unexpected chat response.");
var vector = await service.EmbedAsync("hello");
Assert(vector.Count == 3, "Unexpected embed vector length.");
Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value.");
Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value.");
Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestPythonSidecarAiServiceErrorAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py");
File.WriteAllText(scriptPath, """
import json, sys
_ = json.loads(sys.stdin.readline())
print(json.dumps({"ok": False, "error": "simulated failure"}))
""");
try
{
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
IAiService service = new PythonSidecarAiService(config);
try
{
_ = await service.SummarizeEntryAsync("hello");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase))
{
return;
}
throw new InvalidOperationException("Expected summarize_entry to surface sidecar error.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestPythonSidecarSpeechServiceNoDevicesAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var scriptPath = Path.Combine(root, "fake_speech_sidecar_nodes.py");
File.WriteAllText(scriptPath, """
import json, sys
request = json.loads(sys.stdin.readline())
action = request.get("action", "")
if action == "speech.devices.list":
print(json.dumps({"ok": True, "data": {"devices": [], "warning": "no devices"}}))
elif action == "speech.transcribe":
payload = request.get("payload") or {}
print(json.dumps({"ok": True, "data": {"text": payload.get("text", ""), "engine": payload.get("engine", "whisper")}}))
else:
print(json.dumps({"ok": False, "error": "unknown action"}))
""");
try
{
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
var devices = await service.ListDevicesAsync();
Assert(devices.Devices.Count == 0, "Expected empty devices list.");
Assert((devices.Warning ?? "").Contains("no devices", StringComparison.OrdinalIgnoreCase), "Expected no-devices warning.");
var transcript = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture text", Engine: "whisper"));
Assert(transcript.Text == "fixture text", "Expected passthrough transcript text.");
Assert(transcript.Engine == "whisper", "Expected passthrough transcript engine.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestPythonSidecarSpeechServiceErrorAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var scriptPath = Path.Combine(root, "fake_speech_sidecar_error.py");
File.WriteAllText(scriptPath, """
import json, sys
request = json.loads(sys.stdin.readline())
action = request.get("action", "")
if action == "speech.transcribe":
print(json.dumps({"ok": False, "error": "engine unavailable"}))
else:
print(json.dumps({"ok": True, "data": {"devices": []}}))
""");
try
{
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
try
{
_ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", Engine: "faster-whisper"));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("engine unavailable", StringComparison.OrdinalIgnoreCase))
{
return;
}
throw new InvalidOperationException("Expected speech transcribe to surface sidecar engine error.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestPythonSidecarSpeechServiceTimeoutAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-speech-smoke", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var scriptPath = Path.Combine(root, "fake_speech_sidecar_timeout.py");
File.WriteAllText(scriptPath, """
import json, sys, time
request = json.loads(sys.stdin.readline())
payload = request.get("payload") or {}
sleep_ms = int(payload.get("simulate_delay_ms") or 0)
time.sleep(max(0, sleep_ms) / 1000.0)
print(json.dumps({"ok": True, "data": {"text": "", "engine": "whisper"}}))
""");
try
{
var config = BuildAiConfig(scriptPath, timeoutMs: 100);
ISpeechBridgeService service = new PythonSidecarSpeechService(config);
try
{
_ = await service.TranscribeAsync(new SpeechTranscribeRequestDto(Text: "fixture", SimulateDelayMs: 500));
}
catch (TimeoutException)
{
return;
}
throw new InvalidOperationException("Expected speech transcribe timeout path.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryVaultLoadAllEmptyAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault");
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(vaultDir);
try
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "vault.load_all",
payload = new
{
password = "vault-pass-123",
vaultDirectory = vaultDir,
dataDirectory = dataDir,
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for empty vault directory load.");
Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected vault.load_all data=true for empty vault directory.");
Assert(Directory.Exists(dataDir), "Expected data directory to be created by load workflow.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static async Task TestEntryVaultClearDataDirectoryAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
File.WriteAllText(Path.Combine(dataDir, "tmp.md"), "x");
try
{
var entry = NewEntry();
var request = JsonSerializer.Serialize(new
{
action = "vault.clear_data_directory",
payload = new
{
dataDirectory = dataDir,
}
});
var response = await entry.HandleCommandAsync(request);
using var doc = JsonDocument.Parse(response);
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for clear_data_directory.");
Assert(doc.RootElement.GetProperty("data").GetBoolean(), "Expected clear_data_directory result=true.");
Assert(Directory.Exists(dataDir), "Expected data directory to exist after clear.");
Assert(!Directory.EnumerateFileSystemEntries(dataDir).Any(), "Expected data directory to be empty after clear.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
static Task TestSidecarVaultCliLoadAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault");
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(vaultDir);
try
{
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var exitCode = cli.RunVaultCommand(["load", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]);
Assert(exitCode == 0, "Expected vault load CLI command to succeed on empty vault directory.");
Assert(Directory.Exists(dataDir), "Expected data directory to be created by vault load CLI command.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask;
}
static Task TestSidecarVaultCliSaveAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var vaultDir = Path.Combine(root, "vault");
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(vaultDir);
Directory.CreateDirectory(dataDir);
try
{
File.WriteAllText(Path.Combine(dataDir, "2026-02-22.md"), "entry body");
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var exitCode = cli.RunVaultCommand(["save", "--password", "vault-pass-123", "--vault-dir", vaultDir, "--data-dir", dataDir]);
Assert(exitCode == 0, "Expected vault save CLI command to succeed.");
Assert(File.Exists(Path.Combine(vaultDir, "2026-02.vault")), "Expected monthly vault file to be written by save CLI command.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask;
}
static Task TestSidecarSearchCliFilteredAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
try
{
WriteSearchFixtureFiles(dataDir);
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var (exitCode, stdout, stderr) = CaptureConsole(() => cli.RunSearchCommand(
[
"common",
"--data-dir", dataDir,
"--start-date", "2026-02-01",
"--end-date", "2026-02-28",
"--tag", "stress",
"--type", "!TRIGGER",
"--checked", "med taken",
"--section", "Summary"
]));
Assert(exitCode == 0, "Expected search CLI command to succeed.");
Assert(string.IsNullOrWhiteSpace(stderr), "Expected no stderr output for successful search CLI command.");
Assert(stdout.Contains("--- 2026-02-01 ---", StringComparison.Ordinal), "Expected matching entry header in search CLI output.");
Assert(!stdout.Contains("--- 2026-02-05 ---", StringComparison.Ordinal), "Unexpected non-matching entry in filtered search CLI output.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask;
}
static Task TestSidecarSearchCliEmptyDataAsync()
{
var root = Path.Combine(Path.GetTempPath(), "journal-sidecar-cli-smoke", Guid.NewGuid().ToString("N"));
var dataDir = Path.Combine(root, "data");
Directory.CreateDirectory(dataDir);
try
{
var cli = new SidecarCli(new VaultStorageService(new VaultCryptoService()), new EntrySearchService(), new JournalConfigService());
var (exitCode, stdout, _) = CaptureConsole(() => cli.RunSearchCommand(["--data-dir", dataDir]));
Assert(exitCode == 0, "Expected search CLI command to return success for empty data directory.");
Assert(stdout.Contains("No decrypted journal entries found", StringComparison.OrdinalIgnoreCase), "Expected empty-data guidance message.");
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
return Task.CompletedTask;
}
static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func<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 TestTransportFixturesAsync()
{
var fixtures = await LoadTransportFixturesAsync();
Assert(fixtures.Count > 0, "Transport fixtures should not be empty.");
foreach (var fixture in fixtures)
{
var entry = NewEntry();
var response = await entry.HandleCommandAsync(fixture.Request);
Assert(!response.Contains('\n') && !response.Contains('\r'), $"Fixture '{fixture.Name}' returned multiline output.");
using var doc = JsonDocument.Parse(response);
var ok = doc.RootElement.GetProperty("ok").GetBoolean();
Assert(ok == fixture.ExpectOk, $"Fixture '{fixture.Name}' expected ok={fixture.ExpectOk} but got ok={ok}.");
if (fixture.ExpectOk)
{
Assert(doc.RootElement.TryGetProperty("data", out var data), $"Fixture '{fixture.Name}' expected data field.");
if (!string.IsNullOrWhiteSpace(fixture.DataKind))
{
var expectedKind = ParseValueKind(fixture.DataKind!);
Assert(data.ValueKind == expectedKind, $"Fixture '{fixture.Name}' expected data kind {expectedKind} but got {data.ValueKind}.");
}
continue;
}
Assert(doc.RootElement.TryGetProperty("error", out var error), $"Fixture '{fixture.Name}' expected error field.");
if (!string.IsNullOrWhiteSpace(fixture.ErrorContains))
{
var message = error.GetString() ?? "";
Assert(message.Contains(fixture.ErrorContains!, StringComparison.OrdinalIgnoreCase), $"Fixture '{fixture.Name}' expected error containing '{fixture.ErrorContains}'.");
}
}
}
static async Task<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);
}
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; }
}