using MeetingAssistant.MeetingNotes; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class MeetingNoteStoreTests { [Fact] public async Task StoreWritesTemplateFrontmatterAndReadsUserNotesBody() { var (vaultRoot, store) = CreateStore(); var note = MeetingNoteTemplate.Create( title: "Leadership Sync", startTime: DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"), endTime: DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"), attendees: [ "Mike ", "Ada" ], projects: [ "Meeting Assistant", "Alaric" ], transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"), assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"), summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"), userNotes: "Discuss rollout risks."); var saved = await store.SaveAsync(note, CancellationToken.None); var content = await File.ReadAllTextAsync(saved.Path); var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); Assert.EndsWith(".md", saved.Path, StringComparison.Ordinal); Assert.Contains("attendees:", content); Assert.Contains("- Mike ", content); Assert.Contains("- Ada", content); Assert.Contains("projects:", content); Assert.Contains("- Meeting Assistant", content); Assert.DoesNotContain("purpose:", content); Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content); Assert.Contains("end_time: \"2026-05-19T10:30:00.0000000+02:00\"", content); Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content); Assert.Contains("assistant_context: \"[[../Assistant Context/20260519-context|Assistant Context]]\"", content); Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content); Assert.DoesNotContain("decisions:", content); Assert.DoesNotContain("next_steps:", content); Assert.Equal("Discuss rollout risks.", loaded.UserNotes); Assert.Equal(["Mike ", "Ada"], loaded.Frontmatter.Attendees); Assert.Equal(["Meeting Assistant", "Alaric"], loaded.Frontmatter.Projects); Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"), loaded.Frontmatter.StartTime); Assert.Equal(DateTimeOffset.Parse("2026-05-19T10:30:00+02:00"), loaded.Frontmatter.EndTime); Assert.Equal("[[../Transcripts/20260519-transcript|Transcript]]", loaded.Frontmatter.Transcript); Assert.Equal("[[../Assistant Context/20260519-context|Assistant Context]]", loaded.Frontmatter.AssistantContext); Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary); } [Fact] public async Task StoreNamesGeneratedMeetingNoteWithMinutePrecision() { var (_, store) = CreateStore(); var saved = await store.SaveAsync( MeetingNoteTemplate.Create( title: "Leadership Sync", startTime: DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")), CancellationToken.None); Assert.Equal("20260519-1003-note.md", Path.GetFileName(saved.Path)); } [Fact] public async Task FrontmatterUpdatePreservesExistingUserNotesBody() { var (vaultRoot, store) = CreateStore(); var userNotes = """ ## User notes - First point - Second point ```text Keep this block exactly. ``` """; var saved = await store.SaveAsync( MeetingNoteTemplate.Create( title: "Body Preservation", attendees: [], projects: [], transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"), assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"), summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"), userNotes: userNotes), CancellationToken.None); var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); loaded.Frontmatter.Title = "Updated title"; await store.SaveAsync(loaded, CancellationToken.None); var reloaded = await store.ReadAsync(saved.Path, CancellationToken.None); Assert.Equal("Updated title", reloaded.Frontmatter.Title); Assert.Equal(userNotes, reloaded.UserNotes); } [Fact] public async Task ReadConvertsScalarListFrontmatterToSingleItemLists() { var (vaultRoot, store) = CreateStore(); var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md"); Directory.CreateDirectory(Path.GetDirectoryName(notePath)!); await File.WriteAllTextAsync( notePath, """ --- title: Manual frontmatter start_time: "2026-05-19T10:00:00.0000000+02:00" end_time: "" attendees: Ada projects: Meeting Assistant transcript: "[[../Transcripts/manual-transcript|Transcript]]" assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]" summary: "[[../Summaries/manual-summary|Summary]]" --- User notes. """); var loaded = await store.ReadAsync(notePath, CancellationToken.None); Assert.Equal(["Ada"], loaded.Frontmatter.Attendees); Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects); Assert.Equal("User notes.", loaded.UserNotes); } [Fact] public async Task StoreEscapesApostrophePrefixedAttendeesBeforeWritingFrontmatter() { var (store, saved) = await SaveNoteAsync( title: "Escaped Attendees", attendees: ["'Ada Lovelace"], projects: [], userNotes: "Discuss attendee import."); var content = await File.ReadAllTextAsync(saved.Path); var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); Assert.Contains("- \"'Ada Lovelace\"", content); Assert.Equal(["'Ada Lovelace"], loaded.Frontmatter.Attendees); Assert.Equal("Discuss attendee import.", loaded.UserNotes); } [Theory] [InlineData("Ada # platform lead")] [InlineData("- Ada Lovelace")] [InlineData("? Ada Lovelace")] [InlineData("{Ada: Platform}")] [InlineData("*Ada")] [InlineData("&Ada")] [InlineData("!Ada")] [InlineData("| Ada")] [InlineData("> Ada")] [InlineData("@Ada")] [InlineData("`Ada")] [InlineData("true")] [InlineData("null")] [InlineData("2026-07-08")] [InlineData("Ada \"The Architect\" Lovelace")] [InlineData("C:\\People\\Ada")] public async Task StoreEscapesYamlSensitiveAttendeesBeforeWritingFrontmatter(string attendee) { var (store, saved) = await SaveNoteAsync( title: "Escaped Attendees", attendees: [attendee], projects: [], userNotes: "Discuss attendee import."); var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); Assert.Equal([attendee], loaded.Frontmatter.Attendees); Assert.Equal("Discuss attendee import.", loaded.UserNotes); } [Theory] [InlineData("Planning # Q3")] [InlineData("- Planning")] [InlineData("{Planning: Q3}")] [InlineData("true")] [InlineData("2026-07-08")] public async Task StoreEscapesYamlSensitiveScalarAndProjectFrontmatterValues(string value) { var (store, saved) = await SaveNoteAsync( title: value, attendees: [], projects: [value], userNotes: "Discuss YAML escaping."); var loaded = await store.ReadAsync(saved.Path, CancellationToken.None); Assert.Equal(value, loaded.Frontmatter.Title); Assert.Equal([value], loaded.Frontmatter.Projects); Assert.Equal("Discuss YAML escaping.", loaded.UserNotes); } [Fact] public void ActionLinkEscapesSummaryFileName() { var link = MeetingNoteActionLinks.CreateSummaryRetryLink( "http://localhost:5090/", "C:\\Vault\\Meetings\\Summaries\\summary with spaces.md"); Assert.Equal( "[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)", link); } private static async Task<(MarkdownMeetingNoteStore Store, MeetingNote Saved)> SaveNoteAsync( string title, IReadOnlyList attendees, IReadOnlyList projects, string userNotes) { var (vaultRoot, store) = CreateStore(); var note = MeetingNoteTemplate.Create( title: title, attendees: attendees, projects: projects, transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"), assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"), summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"), userNotes: userNotes); var saved = await store.SaveAsync(note, CancellationToken.None); return (store, saved); } private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore() { var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var store = new MarkdownMeetingNoteStore( Options.Create(new MeetingAssistantOptions { Vault = new VaultOptions { MeetingNotesFolder = Path.Combine(vaultRoot, "Meetings", "Notes"), TranscriptsFolder = Path.Combine(vaultRoot, "Meetings", "Transcripts"), AssistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context"), SummariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries"), ProjectsFolder = Path.Combine(vaultRoot, "Projects"), DictationWordsPath = Path.Combine(vaultRoot, "Meetings", "dictation-words.md") } }), NullLogger.Instance); return (vaultRoot, store); } }