// ingestion/internal/api/handler_test.go package api_test import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" ) // stubComplete returns a fixed JSON RawPage so tests never call a real LLM. func stubComplete(_ context.Context, _, _ string) (string, error) { return `[{"title":"Test Source","type":"source","subtype":"article","content":"## Summary\n\nSome content here.\n"}]`, nil } func stubPipelineCfg() pipeline.Config { return pipeline.Config{ Complete: stubComplete, ChunkSize: 0, Schema: "# Test Schema\nwiki/sources/, wiki/concepts/, wiki/entities/", } } func setup(t *testing.T) (string, *api.Handler) { t.Helper() dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755)) require.NoError(t, os.WriteFile( filepath.Join(dir, "knowledge", "tdd.md"), []byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"), 0o644, )) logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) return dir, api.NewHandler(dir, logger, stubPipelineCfg()) } // --------------------------------------------------------------------------- // Existing tests (Write / Query) // --------------------------------------------------------------------------- func TestQuery_ReturnsResults(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5}) req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Query(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) results := resp["results"].([]any) assert.NotEmpty(t, results) } func TestWrite_CreatesWikiFile(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{ "content": "# Test note\n\nSome content.", "filename": "test-note", "wing": "homelab", "hall": "facts", }) req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Write(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var resp map[string]string require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["path"]) content, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "test-note.md")) require.NoError(t, err) assert.Contains(t, string(content), "Some content.") } // TestWrite_RequiresWingAndHall is the HTTP-contract counterpart of // TestWriteNote_RequiresWingAndHall: a caller (e.g. the MCP brain_write // handler) that omits wing/hall gets a 400, not a silent legacy write. func TestWrite_RequiresWingAndHall(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{"content": "no destination"}) req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Write(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestQuery_RequiresQuery(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{"limit": 5}) req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Query(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) { dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "# Claude session abc (koala)\n\nBody.\n", Filename: "session-koala-abc", Wing: "claude-sessions", Hall: "facts", SourceType: "internal", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.Contains(t, string(got), "source_type: internal") assert.Contains(t, string(got), "wing: claude-sessions") } func TestWriteNote_HallFactsDefaultsSourceTypeInternalWhenUnset(t *testing.T) { dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "manually captured fact.\n", Wing: "agentsquad", Hall: "facts", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) // Most hall=facts entries are first-party (an eval/benchmark the agent ran // itself), not external claims — default to internal, require explicit // opt-out for the rare case that does need a citation (brain-gardener#7). assert.Contains(t, string(got), "source_type: internal") } func TestWriteNote_HallFactsPreservesExplicitExternalSourceType(t *testing.T) { dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "vendor pricing claim, needs a citation.\n", Wing: "agentsquad", Hall: "facts", SourceType: "external", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.Contains(t, string(got), "source_type: external") } func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) { dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "a decision record.\n", Wing: "agentsquad", Hall: "decisions", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.NotContains(t, string(got), "source_type") } func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) { dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n", Filename: "act-runner-host-executor", Wing: "homelab", Hall: "failures", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) body := string(got) // exactly one frontmatter block: only two "---" delimiter lines total assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks") assert.Contains(t, body, "wing: homelab") assert.Contains(t, body, "hall: failures") assert.Contains(t, body, "title: act_runner host-executor") assert.Contains(t, body, "tags: [gitea-actions, act_runner]") assert.Contains(t, body, "# Body") } // TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract // (hyperguild#79 / ADR-0012): with the knowledge/ fallback gone, wing+hall // is the only valid destination, so omitting either is a caller error, not // a silent fallback. func TestWriteNote_RequiresWingAndHall(t *testing.T) { cases := []struct { name string wing string hall string }{ {"neither set", "", ""}, {"wing only", "homelab", ""}, {"hall only", "", "facts"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() _, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "body", Wing: tc.wing, Hall: tc.hall, }) require.Error(t, err) assert.Contains(t, err.Error(), "wing and hall are required") }) } } func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{ "content": "auto name", "wing": "homelab", "hall": "facts", }) req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Write(rec, req) assert.Equal(t, http.StatusOK, rec.Code) entries, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts")) require.NoError(t, err) require.Len(t, entries, 1) assert.True(t, strings.HasSuffix(entries[0].Name(), ".md")) } // TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a // hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON // envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename, // content}. cmd/hyperguild lives in a separate Go module from ingestion // (no shared go.work), so a Go-level test can't import brainClient // directly — this test instead pins the wire contract from the server // side and proves a real `hyperguild brain write ` // invocation writes a real file with real frontmatter under wiki///. // A prior version of this contract test on the client side (in // cmd/hyperguild/http_test.go) used only an httptest.NewServer with a // hand-rolled mock body, so it stayed green through a regression where the // server required wing/hall but the client never sent them (hyperguild#79 // PR review). func TestWrite_CLIPayloadRoundTrips(t *testing.T) { dir, h := setup(t) srv := httptest.NewServer(http.HandlerFunc(h.Write)) defer srv.Close() payload, err := json.Marshal(struct { Wing string `json:"wing"` Hall string `json:"hall"` Filename string `json:"filename"` Content string `json:"content"` }{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"}) require.NoError(t, err) resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) var out struct { Path string `json:"path"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path) written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md")) require.NoError(t, err) assert.Contains(t, string(written), "wing: homelab") assert.Contains(t, string(written), "hall: facts") assert.Contains(t, string(written), "Real write.") } // --------------------------------------------------------------------------- // POST /ingest // --------------------------------------------------------------------------- func TestIngest_Validation(t *testing.T) { cases := []struct { name string body map[string]any }{ {"missing content", map[string]any{"source": "test-source"}}, {"missing source", map[string]any{"content": "some content"}}, {"whitespace content", map[string]any{"content": " ", "source": "test-source"}}, {"whitespace source", map[string]any{"content": "some content", "source": " "}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(tc.body) req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Ingest(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } } func TestIngest_Success(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{ "content": "some content about shape-up methodology", "source": "shape-up-book", "dry_run": true, }) req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Ingest(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) pages, ok := resp["pages"] require.True(t, ok, "response must have pages field") pagesSlice, ok := pages.([]any) require.True(t, ok, "pages must be an array") assert.NotEmpty(t, pagesSlice) } // --------------------------------------------------------------------------- // POST /ingest-path // --------------------------------------------------------------------------- func TestIngestPath_MissingPath(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(map[string]any{"source": "test-source"}) req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestPath(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestIngestPath_File(t *testing.T) { brainDir, h := setup(t) // Create a temp file with content dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149) require.NoError(t, os.MkdirAll(dir, 0o755)) f := filepath.Join(dir, "doc.md") require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644)) body, _ := json.Marshal(map[string]any{ "path": f, "source": "test-doc", "dry_run": true, }) req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestPath(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) pages, ok := resp["pages"] require.True(t, ok, "response must have pages field") pagesSlice, ok := pages.([]any) require.True(t, ok, "pages must be an array") assert.NotEmpty(t, pagesSlice) } // --------------------------------------------------------------------------- // POST /ingest-raw // --------------------------------------------------------------------------- func TestIngestRaw_Validation(t *testing.T) { cases := []struct { name string body map[string]any }{ {"missing source", map[string]any{"pages": []any{map[string]any{"title": "X", "type": "concept", "content": "x"}}}}, {"missing pages", map[string]any{"source": "test-source"}}, {"empty pages", map[string]any{"source": "test-source", "pages": []any{}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, h := setup(t) body, _ := json.Marshal(tc.body) req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestRaw(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } } func TestIngestRaw_Success(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{ "source": "test-article", "pages": []any{ map[string]any{"title": "Test Article", "type": "source", "subtype": "article", "domain": "Testing", "content": "## Summary\n\nThis is a test article about [[Test Concept]].\n"}, map[string]any{"title": "Test Concept", "type": "concept", "domain": "Testing", "content": "A concept for testing.\n"}, }, }) req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestRaw(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) pages := resp["pages"].([]any) assert.Len(t, pages, 2) // Verify files were written sourcePath := filepath.Join(dir, "wiki", "sources", "test-article.md") assert.FileExists(t, sourcePath) conceptPath := filepath.Join(dir, "wiki", "concepts", "test-concept.md") assert.FileExists(t, conceptPath) } func TestIngestRaw_DryRun(t *testing.T) { dir, h := setup(t) body, _ := json.Marshal(map[string]any{ "source": "dry-run-test", "pages": []any{ map[string]any{"title": "Dry Run Source", "type": "source", "subtype": "article", "content": "Content."}, }, "dry_run": true, }) req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestRaw(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) pages := resp["pages"].([]any) assert.NotEmpty(t, pages) // Verify no files were written sourcePath := filepath.Join(dir, "wiki", "sources", "dry-run-test.md") assert.NoFileExists(t, sourcePath) } func TestIngestPath_Directory(t *testing.T) { brainDir, h := setup(t) // Create a temp dir with one .md file dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149) require.NoError(t, os.MkdirAll(dir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644)) body, _ := json.Marshal(map[string]any{ "path": dir, "dry_run": true, }) req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)) rec := httptest.NewRecorder() h.IngestPath(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) pages, ok := resp["pages"] require.True(t, ok, "response must have pages field") pagesSlice, ok := pages.([]any) require.True(t, ok, "pages must be an array") assert.NotEmpty(t, pagesSlice) } // --- brain#21: claim-property envelope on the write path ------------------- func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) { // writeHallNote already passes unknown keys through; this pins that // behaviour for the envelope specifically, so a future refactor of the // merge loop cannot silently drop it. dir := t.TempDir() rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n", Wing: "homelab", Hall: "decisions", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.Contains(t, string(got), "half_life: durable") assert.Contains(t, string(got), "applies: universal") assert.Contains(t, string(got), "claim_provenance: asserted") } func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) { // Fail closed: a value outside the enum must not land. A lint skips what // it cannot parse, so an unvalidated write produces a note that reads as // classified while nothing acts on it. dir := t.TempDir() _, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n", Filename: "bad-envelope", Wing: "homelab", Hall: "facts", }) require.Error(t, err) assert.Contains(t, err.Error(), "half_life") _, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md")) assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind") } func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) { dir := t.TempDir() _, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "---\napplies: homelab\n---\n\nBody.\n", Filename: "no-provenance", Wing: "homelab", Hall: "facts", }) require.Error(t, err) assert.Contains(t, err.Error(), "claim_provenance") _, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md")) assert.True(t, os.IsNotExist(statErr)) } // The HTTP POST /write door reaches WriteNote directly, without the // BrainStore, so the unresolved-wikilink strip has to live in the primitive // or that door stays ungated. func TestWriteNote_UnwrapsUnresolvedWikilinks(t *testing.T) { dir := t.TempDir() _, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts", }) require.NoError(t, err) rel, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "See [[target]] and [[gitea-ci]].\n", Filename: "src", Wing: "a", Hall: "facts", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.Contains(t, string(got), "[[target]]") assert.Contains(t, string(got), "and gitea-ci.") assert.NotContains(t, string(got), "[[gitea-ci]]") } func TestUpdateNote_UnwrapsUnresolvedWikilinks(t *testing.T) { dir := t.TempDir() _, err := api.WriteNote(dir, api.WriteNoteOptions{ Content: "old\n", Filename: "n", Wing: "a", Hall: "facts", }) require.NoError(t, err) rel, _, _, err := api.UpdateNote(dir, api.UpdateNoteOptions{ Wing: "a", Hall: "facts", Slug: "n", Content: "Related: [[secret-hygiene]].\n", Reason: "r", }) require.NoError(t, err) got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) require.NoError(t, err) assert.Contains(t, string(got), "Related: secret-hygiene.") assert.NotContains(t, string(got), "[[secret-hygiene]]") }