Public: anyone can read this; the crew can change it.
A test pins MCP `feed_chips`: switching a chip off hides it from `feed` and `asks`, and bad input changes nothing #7072
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7063-a-test-pins-mcp-feed-chips-switching-a-c → main 1 commit fork
1 file
changed +168
−0
test/mcp-feed-chips.test.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** `feed_chips` through `/mcp`: a switched-off chip leaves `feed` and `asks`, bad input stores nothing, and chips are one's own. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { assignIssue } from "../src/actions/issues.ts"; | |
| 9 | import { createOrg } from "../src/actions/orgs.ts"; | |
| 10 | import { openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { chipsOf } from "../src/core/feed_chips.ts"; | |
| 13 | import { getIssueById } from "../src/core/issues.ts"; | |
| 14 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | setDefaultTimeout(30_000); | |
| 23 | ||
| 24 | describe("MCP feed_chips", () => { | |
| 25 | let dataDir = ""; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let aliceTok = ""; | |
| 31 | let bobTok = ""; | |
| 32 | let pullId = ""; | |
| 33 | ||
| 34 | const chips = (tok: string, args: unknown = {}) => mcpTool(servers.url, tok, "feed_chips", args); | |
| 35 | const feedIds = async (tok: string): Promise<string[]> => | |
| 36 | ((await mcpTool(servers.url, tok, "feed", {})).value.items as { id: string }[]).map((i) => i.id); | |
| 37 | const askCount = async (tok: string): Promise<number> => | |
| 38 | (await mcpTool(servers.url, tok, "asks", {})).value.rows.length; | |
| 39 | const pullItem = async (tok: string) => | |
| 40 | ((await mcpTool(servers.url, tok, "feed", {})).value.items as { id: string; kind: string }[]).filter( | |
| 41 | (i) => i.kind === "pull", | |
| 42 | ); | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-feed-chips-")); | |
| 46 | app = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 500e9, totalBytes: 1000e9 }) }); | |
| 47 | servers = await serve(app); | |
| 48 | for (const name of ["alice", "bob"]) { | |
| 49 | const made = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username: name, | |
| 53 | email: `${name}@forge.local`, | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: name === "alice", | |
| 56 | }); | |
| 57 | if (!made.ok) throw new Error(made.error); | |
| 58 | if (name === "alice") alice = made.value; | |
| 59 | else bob = made.value; | |
| 60 | } | |
| 61 | aliceTok = createToken(app.db, alice.id, "rw", "rw").token; | |
| 62 | bobTok = createToken(app.db, bob.id, "rw", "rw").token; | |
| 63 | await createAgent(app, alice, { username: "bot" }); | |
| 64 | const made = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 65 | if (!made.ok) throw new Error(String(made.error)); | |
| 66 | const repo = made.value; | |
| 67 | await commitToBranch(app, alice, repo, { | |
| 68 | message: "seed", | |
| 69 | files: [{ path: "README.md", content: "# open\n" }], | |
| 70 | }); | |
| 71 | addToCrew(app, alice, repo, "bot"); | |
| 72 | await commitToBranch(app, alice, repo, { | |
| 73 | branch: "bot/1", | |
| 74 | base: "main", | |
| 75 | message: "Chip work", | |
| 76 | files: [{ path: "F.md", content: "1\n" }], | |
| 77 | }); | |
| 78 | const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, { | |
| 79 | title: "Chip work", | |
| 80 | body: "", | |
| 81 | baseBranch: "main", | |
| 82 | headBranch: "bot/1", | |
| 83 | }); | |
| 84 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 85 | pullId = opened.value.id; | |
| 86 | const run = createRun(app.db, { | |
| 87 | repoId: repo.id, | |
| 88 | kind: "check", | |
| 89 | issueId: pullId, | |
| 90 | env: `pull-${opened.value.number}`, | |
| 91 | commitOid: opened.value.headOid, | |
| 92 | reason: "pull", | |
| 93 | }); | |
| 94 | claimRun(app.db, run.id); | |
| 95 | finishRun(app.db, run.id, { state: "ok", summary: "ok" }); | |
| 96 | expect(assignIssue(app, alice, repo, getIssueById(app.db, pullId)!, alice).ok).toBe(true); | |
| 97 | if (!createOrg(app, alice, "guild").ok) throw new Error("org"); | |
| 98 | }); | |
| 99 | ||
| 100 | afterAll(async () => { | |
| 101 | servers.stop(); | |
| 102 | app.close(); | |
| 103 | await rm(dataDir, { recursive: true, force: true }); | |
| 104 | }); | |
| 105 | ||
| 106 | test("with no kind it lists the switched-off chips, none at first", async () => { | |
| 107 | expect((await chips(aliceTok)).value).toEqual({ kinds: [], people: [] }); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a kind chip switched off leaves the pull out of feed and asks; on brings it back", async () => { | |
| 111 | expect(await pullItem(aliceTok)).toHaveLength(1); | |
| 112 | expect(await askCount(aliceTok)).toBe(1); | |
| 113 | const off = await chips(aliceTok, { kind: "kind", value: "reviews", hidden: true }); | |
| 114 | expect(off.value).toEqual({ kinds: ["reviews"], people: [] }); | |
| 115 | expect((await chips(aliceTok)).value.kinds).toEqual(["reviews"]); | |
| 116 | expect(await pullItem(aliceTok)).toHaveLength(0); | |
| 117 | expect(await askCount(aliceTok)).toBe(0); | |
| 118 | const on = await chips(aliceTok, { kind: "kind", value: "reviews", hidden: false }); | |
| 119 | expect(on.value).toEqual({ kinds: [], people: [] }); | |
| 120 | expect(await pullItem(aliceTok)).toHaveLength(1); | |
| 121 | expect(await askCount(aliceTok)).toBe(1); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a person chip hides that person's items, by username", async () => { | |
| 125 | const off = await chips(aliceTok, { kind: "person", value: "bot", hidden: true }); | |
| 126 | expect(off.value).toEqual({ kinds: [], people: ["bot"] }); | |
| 127 | expect(await pullItem(aliceTok)).toHaveLength(0); | |
| 128 | expect(await askCount(aliceTok)).toBe(0); | |
| 129 | expect((await chips(aliceTok, { kind: "person", value: "bot", hidden: false })).value.people).toEqual([]); | |
| 130 | expect(await pullItem(aliceTok)).toHaveLength(1); | |
| 131 | expect(await askCount(aliceTok)).toBe(1); | |
| 132 | }); | |
| 133 | ||
| 134 | test("an unknown name or an org answers not_found", async () => { | |
| 135 | for (const value of ["nobody", "guild"]) { | |
| 136 | const r = await chips(aliceTok, { kind: "person", value, hidden: true }); | |
| 137 | expect(r.error).toContain("not_found"); | |
| 138 | } | |
| 139 | expect(chipsOf(app.db, alice.id).people.size).toBe(0); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a missing or non-boolean hidden, or a kind with no chip, answers invalid and stores nothing", async () => { | |
| 143 | const bads: unknown[] = [ | |
| 144 | { kind: "kind", value: "reviews" }, | |
| 145 | { kind: "kind", value: "reviews", hidden: "true" }, | |
| 146 | { kind: "kind", value: "reviews", hidden: 1 }, | |
| 147 | { kind: "person", value: "bot" }, | |
| 148 | { kind: "person", value: "bot", hidden: "yes" }, | |
| 149 | { kind: "kind", value: "runs", hidden: true }, | |
| 150 | { kind: "kind", hidden: true }, | |
| 151 | ]; | |
| 152 | for (const args of bads) expect((await chips(aliceTok, args)).error).toContain("invalid"); | |
| 153 | const stored = chipsOf(app.db, alice.id); | |
| 154 | expect(stored.kinds.size + stored.people.size).toBe(0); | |
| 155 | expect((await chips(aliceTok)).value).toEqual({ kinds: [], people: [] }); | |
| 156 | }); | |
| 157 | ||
| 158 | test("one person's chips never change another's feed", async () => { | |
| 159 | const before = await feedIds(bobTok); | |
| 160 | await chips(aliceTok, { kind: "kind", value: "reviews", hidden: true }); | |
| 161 | await chips(aliceTok, { kind: "person", value: "bot", hidden: true }); | |
| 162 | expect((await chips(bobTok)).value).toEqual({ kinds: [], people: [] }); | |
| 163 | expect(await feedIds(bobTok)).toEqual(before); | |
| 164 | expect(await pullItem(bobTok)).toHaveLength(1); | |
| 165 | await chips(aliceTok, { kind: "kind", value: "reviews", hidden: false }); | |
| 166 | await chips(aliceTok, { kind: "person", value: "bot", hidden: false }); | |
| 167 | }); | |
| 168 | }); |
new above
offline