2162 lines
86 KiB
C#
2162 lines
86 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.Ai;
|
|
using Journal.Core.Services.Config;
|
|
using Journal.Core.Services.Database;
|
|
using Journal.Core.Services.Entries;
|
|
using Journal.Core.Services.Fragments;
|
|
using Journal.Core.Services.Logging;
|
|
using Journal.Core.Services.Speech;
|
|
using Journal.Core.Services.Sidecar;
|
|
using Journal.Core.Services.Vault;
|
|
|
|
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.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()
|
|
{
|
|
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 CommandLogger());
|
|
}
|
|
|
|
static IJournalDatabaseService NewDatabaseService() => new JournalDatabaseService(new JournalConfigService());
|
|
|
|
static Task TestCreateTrimsAsync()
|
|
{
|
|
var service = NewService();
|
|
var created = service.Create(new CreateFragmentDto(" !TRIGGER ", " stomach drop ", [" stress ", "", " body "]));
|
|
|
|
Assert(created.Type == "!TRIGGER", "Type should be trimmed.");
|
|
Assert(created.Description == "stomach drop", "Description should be trimmed.");
|
|
Assert(created.Tags.Count == 2, "Expected two normalized tags.");
|
|
Assert(created.Tags[0] == "stress" && created.Tags[1] == "body", "Tags should be trimmed and preserved.");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static Task TestUpdateAcceptsTypeAsync()
|
|
{
|
|
var service = NewService();
|
|
var created = service.Create(new CreateFragmentDto("!TRIGGER", "one"));
|
|
var ok = service.Update(created.Id, new UpdateFragmentDto(Type: " !FLASHBACK ", Description: " two ", Tags: [" memory "]));
|
|
|
|
Assert(ok, "Expected update to succeed.");
|
|
var updated = service.GetById(created.Id);
|
|
Assert(updated is not null, "Updated fragment should exist.");
|
|
Assert(updated!.Type == "!FLASHBACK", "Updated type should be trimmed and stored.");
|
|
Assert(updated.Description == "two", "Updated description should be trimmed and stored.");
|
|
Assert(updated.Tags.Count == 1 && updated.Tags[0] == "memory", "Updated tags should be normalized.");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static Task TestUpdateRejectsWhitespaceTypeAsync()
|
|
{
|
|
var service = NewService();
|
|
var created = service.Create(new CreateFragmentDto("!TRIGGER", "desc"));
|
|
|
|
try
|
|
{
|
|
_ = service.Update(created.Id, new UpdateFragmentDto(Type: " "));
|
|
}
|
|
catch (ValidationException)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
throw new InvalidOperationException("Expected ValidationException for whitespace type update.");
|
|
}
|
|
|
|
static Task TestFileRepositoryPersistsAsync()
|
|
{
|
|
var tempRoot = Path.Combine(Path.GetTempPath(), "journal-smoke", Guid.NewGuid().ToString("N"));
|
|
var dataDir = Path.Combine(tempRoot, "data");
|
|
Directory.CreateDirectory(dataDir);
|
|
const string password = "smoke-test-password";
|
|
|
|
try
|
|
{
|
|
// Set up encrypted DB session
|
|
var configService = new JournalConfigService();
|
|
var dbService = new JournalDatabaseService(configService);
|
|
|
|
// First session: create a fragment
|
|
using var session1 = new DatabaseSessionService(dbService);
|
|
session1.SetPassword(password, dataDir);
|
|
var repo1 = new SqliteFragmentRepository(session1);
|
|
var service1 = new FragmentService(repo1);
|
|
var created = service1.Create(new CreateFragmentDto("!TRIGGER", "persist me", ["tag1"]));
|
|
|
|
// Second session: verify persistence
|
|
using var session2 = new DatabaseSessionService(dbService);
|
|
session2.SetPassword(password, dataDir);
|
|
var repo2 = new SqliteFragmentRepository(session2);
|
|
var service2 = new FragmentService(repo2);
|
|
var loaded = service2.GetById(created.Id);
|
|
|
|
Assert(loaded is not null, "Expected fragment to persist across repository instances.");
|
|
Assert(loaded!.Description == "persist me", "Persisted fragment description mismatch.");
|
|
Assert(loaded.Tags.Count == 1 && loaded.Tags[0] == "tag1", "Persisted tags mismatch.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(tempRoot))
|
|
Directory.Delete(tempRoot, recursive: true);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static Task TestJournalEntryModelAsync()
|
|
{
|
|
var fragment = new Fragment("!TRIGGER", "test fragment");
|
|
var section = new ParsedSection(
|
|
"Summary",
|
|
content: ["line one", "- [x] completed thing"],
|
|
checkboxes: new Dictionary<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.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
static async Task TestEntryDatabaseInitializeSchemaAsync()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(root);
|
|
|
|
try
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "db.initialize_schema",
|
|
payload = new
|
|
{
|
|
password = "vault-pass-123",
|
|
dataDirectory = root
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.initialize_schema.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.TryGetProperty("schemaPath", out var schemaPath), "Expected schemaPath in db.initialize_schema response.");
|
|
Assert(schemaPath.ValueKind == JsonValueKind.String, "Expected string schemaPath value.");
|
|
var resolvedPath = schemaPath.GetString() ?? "";
|
|
Assert(File.Exists(resolvedPath), "db.initialize_schema should write schema bootstrap file.");
|
|
var schemaText = File.ReadAllText(resolvedPath);
|
|
Assert(schemaText.Contains("CREATE TABLE IF NOT EXISTS entries", StringComparison.OrdinalIgnoreCase), "schema bootstrap should include entries table.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
static async Task TestEntryDatabaseHydrateWorkspaceAsync()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "journal-db-smoke", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(root);
|
|
|
|
try
|
|
{
|
|
File.WriteAllText(Path.Combine(root, "2026-02-20.md"), "one");
|
|
File.WriteAllText(Path.Combine(root, "2026-02-21.md"), "two");
|
|
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "db.hydrate_workspace",
|
|
payload = new
|
|
{
|
|
password = "vault-pass-123",
|
|
dataDirectory = root
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for db.hydrate_workspace.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
|
|
Assert(data.TryGetProperty("DatabasePath", out var databasePath), "Expected DatabasePath in hydrate payload.");
|
|
Assert(databasePath.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(databasePath.GetString()), "Expected non-empty DatabasePath.");
|
|
Assert(data.TryGetProperty("SchemaBootstrapPath", out var schemaPath), "Expected SchemaBootstrapPath in hydrate payload.");
|
|
Assert(schemaPath.ValueKind == JsonValueKind.String && File.Exists(schemaPath.GetString()), "Expected hydrate to write schema bootstrap file.");
|
|
Assert(data.TryGetProperty("EntryFilesProcessed", out var filesProcessed), "Expected EntryFilesProcessed in hydrate payload.");
|
|
Assert(filesProcessed.ValueKind == JsonValueKind.Number && filesProcessed.GetInt32() == 2, "Expected hydrate to count markdown files in workspace.");
|
|
Assert(data.TryGetProperty("RuntimeReady", out var runtimeReady), "Expected RuntimeReady in hydrate payload.");
|
|
Assert(runtimeReady.ValueKind == JsonValueKind.True, "Expected RuntimeReady=true when SQLCipher runtime hydration succeeds.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
static Task TestConfigServiceParityKeysAsync()
|
|
{
|
|
IJournalConfigService config = new JournalConfigService();
|
|
var current = config.Current;
|
|
|
|
Assert(!string.IsNullOrWhiteSpace(current.ProjectRoot), "Config ProjectRoot should not be empty.");
|
|
Assert(!string.IsNullOrWhiteSpace(current.AppDirectory), "Config AppDirectory should not be empty.");
|
|
Assert(!string.IsNullOrWhiteSpace(current.DataDirectory), "Config DataDirectory should not be empty.");
|
|
Assert(!string.IsNullOrWhiteSpace(current.VaultDirectory), "Config VaultDirectory should not be empty.");
|
|
Assert(current.MonthlyVaultFormat == "%Y-%m.vault", "Config MonthlyVaultFormat should match Python format token.");
|
|
|
|
Assert(current.LlamaCppUrl == "http://127.0.0.1:8085/v1/completions", "Config LlamaCppUrl default mismatch.");
|
|
Assert(current.LlamaCppModel == "qwen/qwen3-4b", "Config LlamaCppModel default mismatch.");
|
|
Assert(current.EmbeddingApiUrl == "http://127.0.0.1:8086/v1/embeddings", "Config EmbeddingApiUrl default mismatch.");
|
|
Assert(current.SpeechRecognitionEngine == "whisper", "Config SpeechRecognitionEngine default mismatch.");
|
|
Assert(current.WhisperModelSize == "base", "Config WhisperModelSize default mismatch.");
|
|
Assert(current.AiProvider == "none", "Config AiProvider default mismatch.");
|
|
Assert(current.PythonExecutable == "python", "Config PythonExecutable default mismatch.");
|
|
Assert(current.AiSidecarTimeoutMs == 45000, "Config AiSidecarTimeoutMs default mismatch.");
|
|
Assert(current.PythonAiSidecarPath.EndsWith(Path.Combine("journal", "ai", "sidecar.py"), StringComparison.OrdinalIgnoreCase), "Config PythonAiSidecarPath default mismatch.");
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static async Task TestEntryConfigGetAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var response = await entry.HandleCommandAsync("""{"action":"config.get"}""");
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for config.get.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.ValueKind == JsonValueKind.Object, "Expected object payload for config.get.");
|
|
|
|
Assert(data.TryGetProperty("DataDirectory", out var dataDirectory), "Expected DataDirectory in config payload.");
|
|
Assert(dataDirectory.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(dataDirectory.GetString()), "Expected non-empty DataDirectory value.");
|
|
Assert(data.TryGetProperty("MonthlyVaultFormat", out var monthlyVaultFormat), "Expected MonthlyVaultFormat in config payload.");
|
|
Assert(monthlyVaultFormat.GetString() == "%Y-%m.vault", "Expected Python-compatible MonthlyVaultFormat value.");
|
|
Assert(data.TryGetProperty("LlamaCppUrl", out _), "Expected LlamaCppUrl in config payload.");
|
|
Assert(data.TryGetProperty("SpeechRecognitionEngine", out _), "Expected SpeechRecognitionEngine in config payload.");
|
|
}
|
|
|
|
static Task TestLogRedactorScrubsSensitiveFieldsAsync()
|
|
{
|
|
var payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
password = "vault-pass-123",
|
|
content = "private journal body",
|
|
prompt = "private ai prompt",
|
|
nested = new
|
|
{
|
|
token = "abc123"
|
|
}
|
|
});
|
|
|
|
var redacted = LogRedactor.RedactPayload(payload);
|
|
var serialized = JsonSerializer.Serialize(redacted);
|
|
|
|
Assert(!serialized.Contains("vault-pass-123", StringComparison.Ordinal), "Password should be redacted.");
|
|
Assert(!serialized.Contains("private journal body", StringComparison.Ordinal), "Entry content should be redacted.");
|
|
Assert(!serialized.Contains("private ai prompt", StringComparison.Ordinal), "Prompt should be redacted.");
|
|
Assert(!serialized.Contains("abc123", StringComparison.Ordinal), "Nested token should be redacted.");
|
|
Assert(serialized.Contains("[REDACTED]", StringComparison.Ordinal), "Redacted marker should be present.");
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static Task TestLogRedactorPreservesNonSensitiveFieldsAsync()
|
|
{
|
|
var payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
action = "entries.save",
|
|
mode = "Daily",
|
|
filePath = "E:/journal/2026-02-24.md"
|
|
});
|
|
|
|
var redacted = LogRedactor.RedactPayload(payload);
|
|
var serialized = JsonSerializer.Serialize(redacted);
|
|
|
|
Assert(serialized.Contains("entries.save", StringComparison.Ordinal), "Non-sensitive action field should be preserved.");
|
|
Assert(serialized.Contains("Daily", StringComparison.Ordinal), "Non-sensitive mode field should be preserved.");
|
|
Assert(serialized.Contains("2026-02-24.md", StringComparison.Ordinal), "Non-sensitive path field should be preserved.");
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static async Task TestEntryAiHealthDefaultAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var response = await entry.HandleCommandAsync("""{"action":"ai.health"}""");
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for ai.health.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.GetProperty("Enabled").GetBoolean() is false, "Expected AI disabled by default.");
|
|
Assert(string.Equals(data.GetProperty("Provider").GetString(), "none", StringComparison.OrdinalIgnoreCase), "Expected default provider 'none'.");
|
|
}
|
|
|
|
static async Task TestEntryAiSummarizeEntryDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "ai.summarize_entry",
|
|
payload = new
|
|
{
|
|
content = "sample entry"
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_entry.");
|
|
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
|
|
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_entry.");
|
|
}
|
|
|
|
static async Task TestEntryAiSummarizeAllDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "ai.summarize_all",
|
|
payload = new
|
|
{
|
|
entries = new[] { "entry one", "entry two" }
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.summarize_all.");
|
|
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
|
|
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.summarize_all.");
|
|
}
|
|
|
|
static async Task TestEntryAiChatDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "ai.chat",
|
|
payload = new
|
|
{
|
|
prompt = "hello cloud"
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.chat.");
|
|
var data = doc.RootElement.GetProperty("data").GetString() ?? "";
|
|
Assert(data.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled provider message for ai.chat.");
|
|
}
|
|
|
|
static async Task TestEntryAiEmbedDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "ai.embed",
|
|
payload = new
|
|
{
|
|
content = "embedding source text"
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for disabled ai.embed.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.ValueKind == JsonValueKind.Array, "Expected ai.embed response to be a JSON array.");
|
|
Assert(data.GetArrayLength() == 0, "Expected disabled ai.embed to return an empty vector.");
|
|
}
|
|
|
|
static async Task TestEntrySpeechDevicesListDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "speech.devices.list",
|
|
payload = new { }
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.devices.list when disabled.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.devices.list data to be an object.");
|
|
}
|
|
|
|
static async Task TestEntrySpeechTranscribeDisabledAsync()
|
|
{
|
|
var entry = NewEntry();
|
|
var request = JsonSerializer.Serialize(new
|
|
{
|
|
action = "speech.transcribe",
|
|
payload = new
|
|
{
|
|
text = "fixture transcript",
|
|
engine = "whisper"
|
|
}
|
|
});
|
|
|
|
var response = await entry.HandleCommandAsync(request);
|
|
using var doc = JsonDocument.Parse(response);
|
|
Assert(doc.RootElement.GetProperty("ok").GetBoolean(), "Expected ok=true for speech.transcribe when disabled.");
|
|
var data = doc.RootElement.GetProperty("data");
|
|
Assert(data.ValueKind == JsonValueKind.Object, "Expected speech.transcribe data to be an object.");
|
|
var warning = data.TryGetProperty("Warning", out var warningNode) ? warningNode.GetString() ?? "" : "";
|
|
Assert(warning.Contains("disabled", StringComparison.OrdinalIgnoreCase), "Expected disabled speech warning.");
|
|
}
|
|
|
|
static async Task TestPythonSidecarAiServiceJsonLineAsync()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(root);
|
|
var scriptPath = Path.Combine(root, "fake_ai_sidecar.py");
|
|
File.WriteAllText(scriptPath, """
|
|
import json, sys
|
|
request = json.loads(sys.stdin.readline())
|
|
action = request.get("action", "")
|
|
print("DEBUG prelude")
|
|
if action == "health":
|
|
print(json.dumps({"ok": True, "data": {"provider": "python-sidecar", "healthy": True, "message": "ok"}}))
|
|
elif action == "summarize_entry":
|
|
payload = request.get("payload") or {}
|
|
print(json.dumps({"ok": True, "data": "ENTRY::" + str(payload.get("content", ""))}))
|
|
elif action == "summarize_all":
|
|
payload = request.get("payload") or {}
|
|
entries = payload.get("entries") or []
|
|
print(json.dumps({"ok": True, "data": "ALL::" + str(len(entries))}))
|
|
elif action == "chat":
|
|
payload = request.get("payload") or {}
|
|
print(json.dumps({"ok": True, "data": "CHAT::" + str(payload.get("prompt", ""))}))
|
|
elif action == "embed":
|
|
payload = request.get("payload") or {}
|
|
text = str(payload.get("content", ""))
|
|
print(json.dumps({"ok": True, "data": [float(len(text)), 2.5, -1.0]}))
|
|
else:
|
|
print(json.dumps({"ok": False, "error": "unknown action"}))
|
|
""");
|
|
|
|
try
|
|
{
|
|
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
|
IAiService service = new PythonSidecarAiService(config);
|
|
|
|
var health = await service.HealthAsync();
|
|
Assert(health.Enabled, "Expected enabled=true for python-sidecar health.");
|
|
Assert(health.Healthy, "Expected healthy=true from fake sidecar health.");
|
|
|
|
var one = await service.SummarizeEntryAsync("hello");
|
|
Assert(one == "ENTRY::hello", "Unexpected summarize_entry response.");
|
|
|
|
var all = await service.SummarizeAllAsync(["a", "b", "c"]);
|
|
Assert(all == "ALL::3", "Unexpected summarize_all response.");
|
|
|
|
var chat = await service.ChatAsync("hello");
|
|
Assert(chat == "CHAT::hello", "Unexpected chat response.");
|
|
|
|
var vector = await service.EmbedAsync("hello");
|
|
Assert(vector.Count == 3, "Unexpected embed vector length.");
|
|
Assert(Math.Abs(vector[0] - 5d) < 0.0001d, "Unexpected embed vector first value.");
|
|
Assert(Math.Abs(vector[1] - 2.5d) < 0.0001d, "Unexpected embed vector second value.");
|
|
Assert(Math.Abs(vector[2] + 1.0d) < 0.0001d, "Unexpected embed vector third value.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
static async Task TestPythonSidecarAiServiceErrorAsync()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "journal-ai-smoke", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(root);
|
|
var scriptPath = Path.Combine(root, "fake_ai_sidecar_error.py");
|
|
File.WriteAllText(scriptPath, """
|
|
import json, sys
|
|
_ = json.loads(sys.stdin.readline())
|
|
print(json.dumps({"ok": False, "error": "simulated failure"}))
|
|
""");
|
|
|
|
try
|
|
{
|
|
var config = BuildAiConfig(scriptPath, timeoutMs: 4000);
|
|
IAiService service = new PythonSidecarAiService(config);
|
|
|
|
try
|
|
{
|
|
_ = await service.SummarizeEntryAsync("hello");
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("simulated failure", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw new InvalidOperationException("Expected summarize_entry to surface sidecar error.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root))
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
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; }
|
|
}
|