package api import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // seedNote writes a note directly to disk and returns its relPath. func seedNote(t *testing.T, brainDir, rel, content string) string { t.Helper() full := filepath.Join(brainDir, filepath.FromSlash(rel)) require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) return rel } func TestUpdateNoteSupersedesAndStamps(t *testing.T) { brainDir := t.TempDir() rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n") relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{ Path: rel, Content: "# New\n\nnew body\n", Reason: "facts changed", }) require.NoError(t, err) assert.Equal(t, rel, relPath) assert.NotEmpty(t, hash) assert.NotEmpty(t, priorHash) assert.NotEqual(t, hash, priorHash) got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) require.NoError(t, err) s := string(got) // Body replaced. assert.Contains(t, s, "# New") assert.NotContains(t, s, "old body") // Prior fields preserved. assert.Contains(t, s, "wing: jepa-fx") assert.Contains(t, s, "hall: facts") assert.Contains(t, s, "created_at: 2026-01-01T00:00:00Z") assert.Contains(t, s, "custom: keep-me") // Supersession stamped. assert.Contains(t, s, "updated_at:") assert.Contains(t, s, "supersedes: "+priorHash) assert.Contains(t, s, "supersede_reason: facts changed") } func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) { brainDir := t.TempDir() seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", "---\nwing: jepa-fx\nhall: facts\n---\nold\n") relPath, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ Wing: "jepa-fx", Hall: "facts", Slug: "val-vol", Content: "new\n", }) require.NoError(t, err) assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", relPath) } func TestUpdateNoteErrorsOnMissingAndDoesNotCreate(t *testing.T) { brainDir := t.TempDir() _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ Wing: "jepa-fx", Hall: "facts", Slug: "ghost", Content: "x\n", }) require.Error(t, err) assert.Contains(t, err.Error(), "does not exist") // No file created. _, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md")) assert.True(t, os.IsNotExist(statErr), "missing-target update must not create a note") } func TestUpdateNoteRejectsTraversal(t *testing.T) { brainDir := t.TempDir() _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ Path: "../escape.md", Content: "x\n", }) require.Error(t, err) } func TestReadNoteReturnsFrontmatterBodyHash(t *testing.T) { brainDir := t.TempDir() rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/n.md", "---\nwing: jepa-fx\nhall: facts\n---\n# Body\n\ntext\n") fm, body, hash, err := ReadNote(brainDir, rel) require.NoError(t, err) assert.Equal(t, "jepa-fx", fm["wing"]) assert.Equal(t, "facts", fm["hall"]) assert.Equal(t, "# Body\n\ntext\n", body) // Hash matches ContentHash of the raw bytes on disk (round-trip). raw, _ := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) assert.Equal(t, ContentHash(raw), hash) } func TestReadNoteRejectsTraversal(t *testing.T) { brainDir := t.TempDir() _, _, _, err := ReadNote(brainDir, "../../etc/passwd") require.Error(t, err) } func TestUpdateThenReadRoundTripsHash(t *testing.T) { brainDir := t.TempDir() rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n") _, hash, _, err := UpdateNote(brainDir, UpdateNoteOptions{Path: rel, Content: "new\n"}) require.NoError(t, err) _, _, readHash, err := ReadNote(brainDir, rel) require.NoError(t, err) assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote") } func TestContentHashStable(t *testing.T) { assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc"))) assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd"))) assert.True(t, strings.HasPrefix(ContentHash([]byte("")), "")) // hex, non-panicking }