Files
meeting-assistant/MeetingAssistant.Tests/MeetingNoteStoreTests.cs
codex 0e0feedad2
PR and Push Build/Test / build-and-test (push) Successful in 10m21s
Fix YAML escaping for meeting note frontmatter
2026-07-15 14:55:37 +02:00

257 lines
11 KiB
C#

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 <m.ike@ibm.com>",
"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 <m.ike@ibm.com>", 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 <m.ike@ibm.com>", "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<string> attendees,
IReadOnlyList<string> 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<MarkdownMeetingNoteStore>.Instance);
return (vaultRoot, store);
}
}