CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pr-slash-commands.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 15db0e0 | 1 | /** |
| 2 | * Tests for `src/lib/pr-slash-commands.ts`. | |
| 3 | * | |
| 4 | * Two layers (matching the project convention): | |
| 5 | * 1. Pure helpers — `parseSlashCommand`, marker detection, | |
| 6 | * `parseMergeStrategy`, `/help` rendering. Run unconditionally. | |
| 7 | * 2. End-to-end with a real DB row + bare repo. Gated on | |
| 8 | * `DATABASE_URL` via `HAS_DB` skipIf, matching ai-ci-healer.test.ts. | |
| 9 | * | |
| 10 | * All AI calls are stubbed via the `deps.anthropic` injection point. | |
| 11 | * The merge call is stubbed via `deps.merge` to avoid touching real | |
| 12 | * git refs (the executor's job is to format the result, not to re-test | |
| 13 | * pr-merge.ts). | |
| 14 | */ | |
| 15 | ||
| 16 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 17 | import { join } from "path"; | |
| 18 | import { mkdir, rm } from "fs/promises"; | |
| 19 | ||
| 20 | import { | |
| 21 | parseSlashCommand, | |
| 22 | executeSlashCommand, | |
| 23 | detectSlashCmdComment, | |
| 24 | stripSlashCmdMarker, | |
| 25 | slashCmdMarker, | |
| 26 | SLASH_COMMANDS, | |
| 27 | __test, | |
| 28 | } from "../lib/pr-slash-commands"; | |
| 29 | import { db } from "../db"; | |
| 30 | import { | |
| 31 | pullRequests, | |
| 32 | repositories, | |
| 33 | users, | |
| 34 | workflows, | |
| 35 | } from "../db/schema"; | |
| 36 | import { initBareRepo, createOrUpdateFileOnBranch } from "../git/repository"; | |
| 37 | ||
| 38 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 39 | ||
| 40 | const TEST_REPOS = join( | |
| 41 | import.meta.dir, | |
| 42 | "../../.test-repos-pr-slash-" + Date.now() | |
| 43 | ); | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | process.env.GIT_REPOS_PATH = TEST_REPOS; | |
| 47 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 48 | await mkdir(TEST_REPOS, { recursive: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | afterAll(async () => { | |
| 52 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | // --------------------------------------------------------------------------- | |
| 56 | // 1. Pure parser surface — runs without a DB | |
| 57 | // --------------------------------------------------------------------------- | |
| 58 | ||
| 59 | describe("parseSlashCommand", () => { | |
| 60 | it("returns null for empty input", () => { | |
| 61 | expect(parseSlashCommand("")).toBeNull(); | |
| 62 | expect(parseSlashCommand(" ")).toBeNull(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("returns null for free-form text", () => { | |
| 66 | expect(parseSlashCommand("hey team, take a look")).toBeNull(); | |
| 67 | expect(parseSlashCommand("hey /merge")).toBeNull(); // must start at col 0 | |
| 68 | }); | |
| 69 | ||
| 70 | it("returns null for Unix-path-like comments that start with /", () => { | |
| 71 | expect(parseSlashCommand("/usr/local/bin/foo")).toBeNull(); | |
| 72 | expect(parseSlashCommand("/etc/passwd is interesting")).toBeNull(); | |
| 73 | }); | |
| 74 | ||
| 75 | it("recognises every command without args", () => { | |
| 76 | for (const cmd of SLASH_COMMANDS) { | |
| 77 | const parsed = parseSlashCommand(`/${cmd}`); | |
| 78 | expect(parsed).not.toBeNull(); | |
| 79 | expect(parsed!.command).toBe(cmd); | |
| 80 | expect(parsed!.args).toEqual([]); | |
| 81 | } | |
| 82 | }); | |
| 83 | ||
| 84 | it("parses /merge with a strategy arg", () => { | |
| 85 | expect(parseSlashCommand("/merge squash")).toEqual({ | |
| 86 | command: "merge", | |
| 87 | args: ["squash"], | |
| 88 | raw: "merge squash", | |
| 89 | }); | |
| 90 | expect(parseSlashCommand("/merge rebase")).toEqual({ | |
| 91 | command: "merge", | |
| 92 | args: ["rebase"], | |
| 93 | raw: "merge rebase", | |
| 94 | }); | |
| 95 | expect(parseSlashCommand("/merge merge")).toEqual({ | |
| 96 | command: "merge", | |
| 97 | args: ["merge"], | |
| 98 | raw: "merge merge", | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | it("parses /cc with multiple @users", () => { | |
| 103 | const p = parseSlashCommand("/cc @alice @bob @carol"); | |
| 104 | expect(p).not.toBeNull(); | |
| 105 | expect(p!.command).toBe("cc"); | |
| 106 | expect(p!.args).toEqual(["@alice", "@bob", "@carol"]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("only inspects the first non-blank line", () => { | |
| 110 | const p = parseSlashCommand("/needs-work\nplease tighten the loop body"); | |
| 111 | expect(p).not.toBeNull(); | |
| 112 | expect(p!.command).toBe("needs-work"); | |
| 113 | expect(p!.args).toEqual([]); | |
| 114 | }); | |
| 115 | ||
| 116 | it("normalises trailing punctuation", () => { | |
| 117 | expect(parseSlashCommand("/help.")!.command).toBe("help"); | |
| 118 | expect(parseSlashCommand("/HELP")!.command).toBe("help"); | |
| 119 | }); | |
| 120 | ||
| 121 | it("ignores a leading space after the slash", () => { | |
| 122 | expect(parseSlashCommand("/ merge")).toBeNull(); | |
| 123 | }); | |
| 124 | }); | |
| 125 | ||
| 126 | describe("detectSlashCmdComment / stripSlashCmdMarker", () => { | |
| 127 | it("detects each command marker", () => { | |
| 128 | for (const cmd of SLASH_COMMANDS) { | |
| 129 | const body = `${slashCmdMarker(cmd)}\n\nbody text`; | |
| 130 | expect(detectSlashCmdComment(body)).toBe(cmd); | |
| 131 | expect(stripSlashCmdMarker(body)).toBe("body text"); | |
| 132 | } | |
| 133 | }); | |
| 134 | ||
| 135 | it("returns null for normal comments", () => { | |
| 136 | expect(detectSlashCmdComment("just a plain comment")).toBeNull(); | |
| 137 | expect(detectSlashCmdComment("<!-- something else -->")).toBeNull(); | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 141 | describe("__test.parseMergeStrategy", () => { | |
| 142 | it("defaults to merge when the arg is missing or junk", () => { | |
| 143 | expect(__test.parseMergeStrategy(undefined)).toBe("merge"); | |
| 144 | expect(__test.parseMergeStrategy("")).toBe("merge"); | |
| 145 | expect(__test.parseMergeStrategy("rocket")).toBe("merge"); | |
| 146 | }); | |
| 147 | it("accepts the three documented strategies", () => { | |
| 148 | expect(__test.parseMergeStrategy("squash")).toBe("squash"); | |
| 149 | expect(__test.parseMergeStrategy("rebase")).toBe("rebase"); | |
| 150 | expect(__test.parseMergeStrategy("merge")).toBe("merge"); | |
| 151 | expect(__test.parseMergeStrategy("SQUASH")).toBe("squash"); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | // --------------------------------------------------------------------------- | |
| 156 | // 2. Executor — /help runs without any deps | |
| 157 | // --------------------------------------------------------------------------- | |
| 158 | ||
| 159 | describe("executeSlashCommand — /help", () => { | |
| 160 | it("renders the full command list with the marker", async () => { | |
| 161 | const result = await executeSlashCommand({ | |
| 162 | command: "help", | |
| 163 | args: [], | |
| 164 | prId: "00000000-0000-0000-0000-000000000000", | |
| 165 | userId: "00000000-0000-0000-0000-000000000000", | |
| 166 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 167 | }); | |
| 168 | expect(result.ok).toBe(true); | |
| 169 | expect(result.marker).toBe(slashCmdMarker("help")); | |
| 170 | expect(result.body).toContain(slashCmdMarker("help")); | |
| 171 | // Every recognised command is documented in the help output. | |
| 172 | for (const cmd of SLASH_COMMANDS) { | |
| 173 | expect(result.body).toContain(`/${cmd}`); | |
| 174 | } | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | describe("executeSlashCommand — unrecognised input never throws", () => { | |
| 179 | it("does not throw when no DB is available", async () => { | |
| 180 | // parseSlashCommand would normally reject this; we call execute | |
| 181 | // directly to make sure the route-side defence-in-depth is fine. | |
| 182 | const result = await executeSlashCommand({ | |
| 183 | // @ts-expect-error — exercising the default-case branch | |
| 184 | command: "does-not-exist", | |
| 185 | args: [], | |
| 186 | prId: "00000000-0000-0000-0000-000000000000", | |
| 187 | userId: "00000000-0000-0000-0000-000000000000", | |
| 188 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 189 | }); | |
| 190 | expect(result.ok).toBe(false); | |
| 191 | expect(result.body).toContain("Unrecognised slash command"); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | // --------------------------------------------------------------------------- | |
| 196 | // 3. DB-backed flows — /explain (mock Claude), /merge (mock performMerge) | |
| 197 | // --------------------------------------------------------------------------- | |
| 198 | ||
| 199 | function fakeAnthropic(responseText: string) { | |
| 200 | return { | |
| 201 | messages: { | |
| 202 | create: async () => ({ | |
| 203 | id: "msg_test", | |
| 204 | type: "message" as const, | |
| 205 | role: "assistant" as const, | |
| 206 | model: "claude-sonnet-4-test", | |
| 207 | stop_reason: "end_turn" as const, | |
| 208 | stop_sequence: null, | |
| 209 | usage: { input_tokens: 0, output_tokens: 0 }, | |
| 210 | content: [{ type: "text" as const, text: responseText }], | |
| 211 | }), | |
| 212 | }, | |
| 213 | } as any; | |
| 214 | } | |
| 215 | ||
| 216 | interface SlashFixture { | |
| 217 | userId: string; | |
| 218 | repoId: string; | |
| 219 | prId: string; | |
| 220 | ownerUsername: string; | |
| 221 | repoName: string; | |
| 222 | } | |
| 223 | ||
| 224 | async function seedFixture(label: string): Promise<SlashFixture> { | |
| 225 | const username = `slash_${label}_${Date.now()}_${Math.random() | |
| 226 | .toString(36) | |
| 227 | .slice(2, 6)}`; | |
| 228 | const [u] = await db | |
| 229 | .insert(users) | |
| 230 | .values({ | |
| 231 | username, | |
| 232 | email: `${username}@example.com`, | |
| 233 | passwordHash: "x", | |
| 234 | }) | |
| 235 | .returning({ id: users.id }); | |
| 236 | ||
| 237 | const repoName = `subject_${label}_${Date.now()}`; | |
| 238 | const [r] = await db | |
| 239 | .insert(repositories) | |
| 240 | .values({ | |
| 241 | ownerId: u.id, | |
| 242 | name: repoName, | |
| 243 | diskPath: `/tmp/${username}/${repoName}`, | |
| 244 | defaultBranch: "main", | |
| 245 | }) | |
| 246 | .returning({ id: repositories.id }); | |
| 247 | ||
| 248 | await initBareRepo(username, repoName); | |
| 249 | // Seed a base commit on main + a head commit on feat-x so the diff | |
| 250 | // /explain consumes is non-empty. | |
| 251 | await createOrUpdateFileOnBranch({ | |
| 252 | owner: username, | |
| 253 | name: repoName, | |
| 254 | branch: "main", | |
| 255 | filePath: "src/index.ts", | |
| 256 | bytes: new TextEncoder().encode("export const v = 1;\n"), | |
| 257 | message: "base", | |
| 258 | authorName: "Seeder", | |
| 259 | authorEmail: "s@e.com", | |
| 260 | }); | |
| 261 | await createOrUpdateFileOnBranch({ | |
| 262 | owner: username, | |
| 263 | name: repoName, | |
| 264 | branch: "feat-x", | |
| 265 | filePath: "src/index.ts", | |
| 266 | bytes: new TextEncoder().encode("export const v = 2;\n"), | |
| 267 | message: "feat: bump v", | |
| 268 | authorName: "Seeder", | |
| 269 | authorEmail: "s@e.com", | |
| 270 | }); | |
| 271 | ||
| 272 | const [pr] = await db | |
| 273 | .insert(pullRequests) | |
| 274 | .values({ | |
| 275 | repositoryId: r.id, | |
| 276 | number: 1, | |
| 277 | title: "Bump v", | |
| 278 | body: "Bumps the version constant.", | |
| 279 | authorId: u.id, | |
| 280 | baseBranch: "main", | |
| 281 | headBranch: "feat-x", | |
| 282 | state: "open", | |
| 283 | }) | |
| 284 | .returning({ id: pullRequests.id }); | |
| 285 | ||
| 286 | return { | |
| 287 | userId: u.id, | |
| 288 | repoId: r.id, | |
| 289 | prId: pr.id, | |
| 290 | ownerUsername: username, | |
| 291 | repoName, | |
| 292 | }; | |
| 293 | } | |
| 294 | ||
| 295 | describe.skipIf(!HAS_DB)("executeSlashCommand — /explain (DB-backed)", () => { | |
| 296 | it("calls Claude and returns its explanation in the result body", async () => { | |
| 297 | const fx = await seedFixture("explain"); | |
| 298 | const result = await executeSlashCommand({ | |
| 299 | command: "explain", | |
| 300 | args: [], | |
| 301 | prId: fx.prId, | |
| 302 | userId: fx.userId, | |
| 303 | repositoryId: fx.repoId, | |
| 304 | deps: { anthropic: fakeAnthropic("This PR bumps `v` from 1 to 2.") }, | |
| 305 | }); | |
| 306 | expect(result.ok).toBe(true); | |
| 307 | expect(result.body).toContain(slashCmdMarker("explain")); | |
| 308 | expect(result.body).toContain("This PR bumps `v` from 1 to 2."); | |
| 309 | }, 15_000); | |
| 310 | }); | |
| 311 | ||
| 312 | describe.skipIf(!HAS_DB)("executeSlashCommand — /merge (DB-backed)", () => { | |
| 313 | it("forwards the requested strategy and reports the merge result", async () => { | |
| 314 | const fx = await seedFixture("merge"); | |
| 315 | ||
| 39193f6 | 316 | const observed: { actor: string | null } = { actor: null }; |
| 15db0e0 | 317 | const fakeMerge = async (mergeArgs: any) => { |
| 39193f6 | 318 | observed.actor = mergeArgs.actorUserId; |
| 15db0e0 | 319 | expect(mergeArgs.pr.id).toBe(fx.prId); |
| 320 | expect(mergeArgs.pr.headBranch).toBe("feat-x"); | |
| 321 | expect(mergeArgs.pr.baseBranch).toBe("main"); | |
| 322 | return { | |
| 323 | ok: true as const, | |
| 324 | closedIssueNumbers: [42], | |
| 325 | resolvedFiles: [], | |
| 326 | }; | |
| 327 | }; | |
| 328 | ||
| 329 | const result = await executeSlashCommand({ | |
| 330 | command: "merge", | |
| 331 | args: ["squash"], | |
| 332 | prId: fx.prId, | |
| 333 | userId: fx.userId, | |
| 334 | repositoryId: fx.repoId, | |
| 335 | deps: { | |
| 336 | merge: fakeMerge as any, | |
| 337 | // Repo owner = userId, so resolveAccess would return "owner" | |
| 338 | // anyway; pin it for determinism + DB isolation. | |
| 339 | resolveAccess: async () => "owner", | |
| 340 | }, | |
| 341 | }); | |
| 342 | expect(result.ok).toBe(true); | |
| 39193f6 | 343 | expect(observed.actor).toBe(fx.userId); |
| 15db0e0 | 344 | expect(result.body).toContain(slashCmdMarker("merge")); |
| 345 | expect(result.body).toContain("Merged"); | |
| 346 | expect(result.body).toContain("/merge squash"); | |
| 347 | expect(result.body).toContain("#42"); | |
| 348 | }); | |
| 349 | ||
| 350 | it("refuses to merge when the actor lacks write access", async () => { | |
| 351 | const fx = await seedFixture("merge-noauth"); | |
| 352 | const result = await executeSlashCommand({ | |
| 353 | command: "merge", | |
| 354 | args: [], | |
| 355 | prId: fx.prId, | |
| 356 | userId: fx.userId, | |
| 357 | repositoryId: fx.repoId, | |
| 358 | deps: { | |
| 359 | merge: async () => { | |
| 360 | throw new Error("should not be called"); | |
| 361 | }, | |
| 362 | resolveAccess: async () => "read", | |
| 363 | }, | |
| 364 | }); | |
| 365 | expect(result.ok).toBe(false); | |
| 366 | expect(result.body).toContain("denied"); | |
| 367 | }); | |
| 368 | ||
| 369 | it("surfaces the underlying performMerge error verbatim", async () => { | |
| 370 | const fx = await seedFixture("merge-fail"); | |
| 371 | const result = await executeSlashCommand({ | |
| 372 | command: "merge", | |
| 373 | args: [], | |
| 374 | prId: fx.prId, | |
| 375 | userId: fx.userId, | |
| 376 | repositoryId: fx.repoId, | |
| 377 | deps: { | |
| 378 | merge: async () => ({ | |
| 379 | ok: false as const, | |
| 380 | error: "git update-ref failed: not-fast-forward", | |
| 381 | closedIssueNumbers: [], | |
| 382 | resolvedFiles: [], | |
| 383 | }), | |
| 384 | resolveAccess: async () => "owner", | |
| 385 | }, | |
| 386 | }); | |
| 387 | expect(result.ok).toBe(false); | |
| 388 | expect(result.body).toContain("not-fast-forward"); | |
| 389 | }); | |
| 390 | }); | |
| 391 | ||
| 392 | describe.skipIf(!HAS_DB)("executeSlashCommand — /lgtm + /needs-work (DB-backed)", () => { | |
| 393 | it("posts an approval-style body for /lgtm", async () => { | |
| 394 | const fx = await seedFixture("lgtm"); | |
| 395 | const result = await executeSlashCommand({ | |
| 396 | command: "lgtm", | |
| 397 | args: [], | |
| 398 | prId: fx.prId, | |
| 399 | userId: fx.userId, | |
| 400 | repositoryId: fx.repoId, | |
| 401 | }); | |
| 402 | expect(result.ok).toBe(true); | |
| 403 | expect(result.body.toLowerCase()).toContain("approved"); | |
| 404 | }); | |
| 405 | ||
| 406 | it("captures the reason given to /needs-work", async () => { | |
| 407 | const fx = await seedFixture("nw"); | |
| 408 | const result = await executeSlashCommand({ | |
| 409 | command: "needs-work", | |
| 410 | args: ["please", "tighten", "the", "loop"], | |
| 411 | prId: fx.prId, | |
| 412 | userId: fx.userId, | |
| 413 | repositoryId: fx.repoId, | |
| 414 | }); | |
| 415 | expect(result.ok).toBe(true); | |
| 416 | expect(result.body).toContain("please tighten the loop"); | |
| 417 | }); | |
| 418 | }); | |
| 419 | ||
| 420 | describe.skipIf(!HAS_DB)("executeSlashCommand — /cc (DB-backed)", () => { | |
| 421 | it("resolves known users and flags unknowns", async () => { | |
| 422 | const fx = await seedFixture("cc"); | |
| 423 | const knownName = `slash_cc_known_${Date.now()}`; | |
| 424 | await db | |
| 425 | .insert(users) | |
| 426 | .values({ | |
| 427 | username: knownName, | |
| 428 | email: `${knownName}@example.com`, | |
| 429 | passwordHash: "x", | |
| 430 | }); | |
| 431 | ||
| 432 | const result = await executeSlashCommand({ | |
| 433 | command: "cc", | |
| 434 | args: [`@${knownName}`, "@nobody-knows-this"], | |
| 435 | prId: fx.prId, | |
| 436 | userId: fx.userId, | |
| 437 | repositoryId: fx.repoId, | |
| 438 | }); | |
| 439 | expect(result.ok).toBe(true); | |
| 440 | expect(result.body).toContain(knownName); | |
| 441 | expect(result.body).toContain("Skipped"); | |
| 442 | }); | |
| 443 | ||
| 444 | it("rejects when no @users are supplied", async () => { | |
| 445 | const fx = await seedFixture("cc-empty"); | |
| 446 | const result = await executeSlashCommand({ | |
| 447 | command: "cc", | |
| 448 | args: [], | |
| 449 | prId: fx.prId, | |
| 450 | userId: fx.userId, | |
| 451 | repositoryId: fx.repoId, | |
| 452 | }); | |
| 453 | expect(result.ok).toBe(false); | |
| 454 | expect(result.body).toContain("requires one or more"); | |
| 455 | }); | |
| 456 | }); | |
| 457 | ||
| 458 | describe.skipIf(!HAS_DB)("executeSlashCommand — /test (DB-backed)", () => { | |
| 459 | it("enqueues a workflow_dispatch when a test.yml workflow exists", async () => { | |
| 460 | const fx = await seedFixture("test"); | |
| 461 | const [w] = await db | |
| 462 | .insert(workflows) | |
| 463 | .values({ | |
| 464 | repositoryId: fx.repoId, | |
| 465 | name: "Tests", | |
| 466 | path: ".gluecron/workflows/test.yml", | |
| 467 | yaml: "name: Tests\non: [push, workflow_dispatch]\njobs:\n t:\n steps:\n - run: bun test\n", | |
| 468 | parsed: JSON.stringify({ | |
| 469 | name: "Tests", | |
| 470 | on: ["push", "workflow_dispatch"], | |
| 471 | jobs: { t: { steps: [{ run: "bun test" }] } }, | |
| 472 | }), | |
| 473 | onEvents: JSON.stringify(["push", "workflow_dispatch"]), | |
| 474 | }) | |
| 475 | .returning({ id: workflows.id }); | |
| 476 | ||
| 477 | const result = await executeSlashCommand({ | |
| 478 | command: "test", | |
| 479 | args: [], | |
| 480 | prId: fx.prId, | |
| 481 | userId: fx.userId, | |
| 482 | repositoryId: fx.repoId, | |
| 483 | }); | |
| 484 | expect(result.ok).toBe(true); | |
| 485 | expect(result.body).toContain("dispatched"); | |
| 486 | ||
| 487 | // Sanity: the lookup helper actually finds the row we just inserted. | |
| 488 | const found = await __test.findTestWorkflow(fx.repoId); | |
| 489 | expect(found?.id).toBe(w.id); | |
| 490 | }, 15_000); | |
| 491 | ||
| 492 | it("falls back gracefully when no test workflow is configured", async () => { | |
| 493 | const fx = await seedFixture("test-missing"); | |
| 494 | const result = await executeSlashCommand({ | |
| 495 | command: "test", | |
| 496 | args: [], | |
| 497 | prId: fx.prId, | |
| 498 | userId: fx.userId, | |
| 499 | repositoryId: fx.repoId, | |
| 500 | }); | |
| 501 | expect(result.ok).toBe(false); | |
| 502 | expect(result.body).toContain("could not find a test workflow"); | |
| 503 | }); | |
| 504 | }); | |
| 505 | ||
| 506 | // --------------------------------------------------------------------------- | |
| 507 | // 4. Integration smoke — parse → execute round-trip preserves identity | |
| 508 | // --------------------------------------------------------------------------- | |
| 509 | ||
| 510 | describe("parse → execute round-trip (no DB needed)", () => { | |
| 511 | it("a slash that doesn't pass parseSlashCommand stays a normal comment", () => { | |
| 512 | // The route handler stores the comment verbatim and only THEN consults | |
| 513 | // parseSlashCommand. This test asserts the contract: | |
| 514 | // - "/usr/bin/x" returns null → route does NOT execute anything | |
| 515 | // - "/help" returns a parse → route executes | |
| 516 | expect(parseSlashCommand("/usr/bin/x")).toBeNull(); | |
| 517 | expect(parseSlashCommand("hello world")).toBeNull(); | |
| 518 | expect(parseSlashCommand("/help")).not.toBeNull(); | |
| 519 | }); | |
| 520 | }); |