Files
Josemi Liébana b7ef0756d9 fix(annotate): add /api/save-notes POST endpoint to annotate server (#884)
* fix(annotate): add /api/save-notes POST endpoint to both servers

Copies the save-notes route from the plan review server into the
annotate server (Bun source and Pi extension copy), enabling Save to
Obsidian in annotation mode.

Fixes #844

* test(annotate): add saveToObsidian unit tests and HTTP endpoint tests

Verifies saveToObsidian writes files correctly and handles missing
vaults. HTTP endpoint tests cover success, empty integrations, and
integration-level error (not 500).

Imports consolidation from ./integrations into a single statement.

* fix(annotate): normalize server port fallback

* refactor(server): extract shared handleSaveNotes handler, fix catch-block bug

Move the /api/save-notes logic into shared handler modules
(shared-handlers.ts for Bun, handlers.ts for Pi) following the existing
pattern for handleImage, handleUpload, handleDraftSave. Replaces four
inline copies with two canonical implementations.

Fixes:
- Bun annotate catch block now correctly returns 500 (was logging only)
- Misindented brace in Pi serverAnnotate.ts resolved by extraction
- Revert unrelated port fallback change (keep server.port! for consistency)
- Static imports in integrations.test.ts
- Add /api/save-notes to CLAUDE.md Annotate Server API table

* fix(opencode): inject annotate server starter instead of global mock.module

commands.test.ts mocked the annotate server with
`mock.module("@plannotator/server/annotate", ...)`. Bun module mocks are
process-global and cannot be unset (oven-sh/bun#7823, #12823), so the stub
leaked into every suite that runs after it — in particular any test that boots
the real annotate server received a stub with no `.url`.

Make `startAnnotateServer` injectable through the existing CommandDeps
(defaulting to the real import, so production is unchanged) and have the test
pass its stub that way. This keeps the fake local to the opencode suite and
unblocks real annotate-server integration tests.

* test(server): cover save-notes — handler unit tests + annotate e2e wiring

- shared-handlers.test.ts: unit-test handleSaveNotes directly (Obsidian write,
  empty integrations, integration-error reported not thrown, 500 on bad body).
- annotate.test.ts: boot the real annotate server and POST /api/save-notes,
  asserting it is served as JSON (not the SPA HTML catch-all) — the regression
  guard for #844. Now possible because the opencode suite no longer installs a
  global annotate module mock.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-16 21:38:39 -07:00

212 lines
6.7 KiB
TypeScript

/**
* Bear Integration Tests
*
* Run: bun test packages/server/integrations.test.ts
*/
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import {
extractTitle,
extractTags,
stripH1,
buildHashtags,
buildBearContent,
saveToObsidian,
} from "./integrations";
describe("extractTitle", () => {
test("extracts plain H1", () => {
expect(extractTitle("# My Plan\n\nContent")).toBe("My Plan");
});
test("strips Implementation Plan: prefix", () => {
expect(extractTitle("# Implementation Plan: Auth Flow\n\nContent")).toBe("Auth Flow");
});
test("strips Plan: prefix", () => {
expect(extractTitle("# Plan: Database Migration\n\nContent")).toBe("Database Migration");
});
test("falls back to 'Plan' when no H1", () => {
expect(extractTitle("No heading here")).toBe("Plan");
});
test("truncates to 50 chars", () => {
const long = "A".repeat(60);
expect(extractTitle(`# ${long}`).length).toBe(50);
});
test("removes special characters", () => {
expect(extractTitle("# Fix [bug] #123")).toBe("Fix bug 123");
});
});
describe("stripH1", () => {
test("strips first H1 line", () => {
expect(stripH1("# My Plan\n\n## Section\nContent")).toBe("## Section\nContent");
});
test("strips H1 with any wording", () => {
expect(stripH1("# Whatever Title Here\nBody")).toBe("Body");
});
test("only strips first H1, not subsequent ones", () => {
const input = "# First\n\n# Second\nBody";
expect(stripH1(input)).toBe("# Second\nBody");
});
test("handles plan with no H1", () => {
expect(stripH1("Just text\nMore text")).toBe("Just text\nMore text");
});
test("does not strip ## H2 headings", () => {
expect(stripH1("## Not H1\nBody")).toBe("## Not H1\nBody");
});
});
describe("buildHashtags", () => {
test("uses custom tags when provided", () => {
expect(buildHashtags("plan, work", ["plannotator"])).toBe("#plan #work");
});
test("falls back to auto tags when custom is empty", () => {
expect(buildHashtags("", ["plannotator", "myproject"])).toBe("#plannotator #myproject");
});
test("falls back to auto tags when custom is undefined", () => {
expect(buildHashtags(undefined, ["plannotator"])).toBe("#plannotator");
});
test("filters empty tags from trailing comma", () => {
expect(buildHashtags("plan, work,", ["plannotator"])).toBe("#plan #work");
});
test("handles whitespace-only custom tags as empty", () => {
expect(buildHashtags(" ", ["auto"])).toBe("#auto");
});
test("preserves slashes in nested Bear tags", () => {
expect(buildHashtags("plannotator/plans, work/code", [])).toBe("#plannotator/plans #work/code");
});
test("preserves slashes in auto tags with nested paths", () => {
expect(buildHashtags(undefined, ["plannotator/plans", "work"])).toBe("#plannotator/plans #work");
});
});
describe("buildBearContent", () => {
test("appends tags by default", () => {
const result = buildBearContent("Body text", "#plan #work", "append");
expect(result).toBe("Body text\n\n#plan #work");
});
test("prepends tags when configured", () => {
const result = buildBearContent("Body text", "#plan #work", "prepend");
expect(result).toBe("#plan #work\n\nBody text");
});
});
describe("full Bear content pipeline", () => {
const plan = "# Add user authentication flow\n\n## Context\nSome content here";
test("no double title — H1 stripped from body", () => {
const body = stripH1(plan);
expect(body).not.toContain("# Add user");
expect(body).toStartWith("## Context");
});
test("custom tags prepended after title removal", () => {
const body = stripH1(plan);
const hashtags = buildHashtags("plan, work", []);
const content = buildBearContent(body, hashtags, "prepend");
expect(content).toStartWith("#plan #work");
expect(content).toContain("## Context");
expect(content).not.toContain("# Add user");
});
test("auto tags appended when no custom tags", () => {
const body = stripH1(plan);
const hashtags = buildHashtags("", ["plannotator", "dev"]);
const content = buildBearContent(body, hashtags, "append");
expect(content).toEndWith("#plannotator #dev");
expect(content).toStartWith("## Context");
});
});
describe("extractTags", () => {
test("always includes plannotator tag", async () => {
const tags = await extractTags("# Simple Plan\n\nContent");
expect(tags).toContain("plannotator");
});
test("extracts words from title", async () => {
const tags = await extractTags("# Authentication Service Refactor\n\nContent");
expect(tags).toContain("authentication");
expect(tags).toContain("service");
expect(tags).toContain("refactor");
});
test("filters stop words from title", async () => {
const tags = await extractTags("# Implementation Plan for the System\n\nContent");
expect(tags).not.toContain("implementation");
expect(tags).not.toContain("plan");
expect(tags).not.toContain("the");
expect(tags).not.toContain("for");
});
test("extracts code fence languages", async () => {
const tags = await extractTags("# Plan\n\n```typescript\ncode\n```\n\n```rust\ncode\n```");
expect(tags).toContain("typescript");
expect(tags).toContain("rust");
});
test("skips generic languages", async () => {
const tags = await extractTags("# Plan\n\n```json\n{}\n```\n\n```yaml\nfoo\n```");
expect(tags).not.toContain("json");
expect(tags).not.toContain("yaml");
});
test("limits to 7 tags", async () => {
const tags = await extractTags("# One Two Three Four\n\n```go\n```\n```python\n```\n```ruby\n```\n```swift\n```");
expect(tags.length).toBeLessThanOrEqual(7);
});
});
describe("saveToObsidian", () => {
test("writes plan file to temp vault", async () => {
const tmpDir = mkdtempSync("/tmp/plannotator-vault-");
try {
const result = await saveToObsidian({
vaultPath: tmpDir,
folder: "plannotator",
plan: "# Test Plan\n\nSome content",
});
expect(result.success).toBe(true);
expect(result.path).toBeString();
expect(result.path).toContain(tmpDir);
expect(result.path).toContain("plannotator");
const exists = Bun.file(result.path!).size > 0;
expect(exists).toBe(true);
const content = await Bun.file(result.path!).text();
expect(content).toContain("# Test Plan");
expect(content).toContain("[[Plannotator Plans]]");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("fails when vault path does not exist", async () => {
const result = await saveToObsidian({
vaultPath: "/nonexistent/vault",
folder: "plannotator",
plan: "# Plan",
});
expect(result.success).toBe(false);
expect(result.error).toBeString();
});
});