Skip to content

Commit 2e1b132

Browse files
chapterjasonclaude
andcommitted
test: add comprehensive test suite for all packages (82 tests)
- packages/core: queries.test.js (16 tests) covering now(), requireItem, requireVersion, bumpVersion, buildChecklistTree, fullItem, allSummaries, wouldCycle - packages/core: sse.test.js (9 tests) covering SSEBroadcaster register/broadcast/cleanup - packages/api: auth.test.js (10 tests) covering hashKey, generateApiKey, checkRateLimit - packages/mcp: tools.test.js (19 tests) covering all MCP tool handlers via LocalStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0a5fd99 commit 2e1b132

4 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { hashKey, generateApiKey } from "../auth/auth.js";
3+
4+
// ---- hashKey ----
5+
describe("hashKey()", () => {
6+
it("returns a 64-char hex string", () => {
7+
const h = hashKey("sk-proj-abc123");
8+
expect(h).toMatch(/^[0-9a-f]{64}$/);
9+
});
10+
11+
it("is deterministic", () => {
12+
expect(hashKey("foo")).toBe(hashKey("foo"));
13+
});
14+
15+
it("is different for different inputs", () => {
16+
expect(hashKey("a")).not.toBe(hashKey("b"));
17+
});
18+
});
19+
20+
// ---- generateApiKey ----
21+
describe("generateApiKey()", () => {
22+
it("starts with sk-proj-", () => {
23+
expect(generateApiKey()).toMatch(/^sk-proj-/);
24+
});
25+
26+
it("generates unique keys", () => {
27+
expect(generateApiKey()).not.toBe(generateApiKey());
28+
});
29+
30+
it("has 56 chars total (sk-proj- + 48 hex chars)", () => {
31+
expect(generateApiKey()).toHaveLength(56);
32+
});
33+
});
34+
35+
// ---- checkRateLimit ----
36+
// Import lazily so we can control Date.now() before the module's WINDOW_MS is read.
37+
// Rate limit state is module-level so we isolate by re-importing fresh per suite.
38+
describe("checkRateLimit()", () => {
39+
let checkRateLimit;
40+
let nowMs;
41+
42+
beforeEach(async () => {
43+
nowMs = 1_000_000;
44+
vi.spyOn(Date, "now").mockImplementation(() => nowMs);
45+
46+
// Reset module registry so counters Map is fresh
47+
vi.resetModules();
48+
({ checkRateLimit } = await import("../auth/rate-limit.js"));
49+
});
50+
51+
afterEach(() => {
52+
vi.restoreAllMocks();
53+
});
54+
55+
it("returns null when under the limit", () => {
56+
expect(checkRateLimit("proj")).toBeNull();
57+
});
58+
59+
it("returns retryAfter object when limit exceeded", () => {
60+
const max = parseInt(process.env.BACKLOG_RATE_LIMIT_MAX ?? "200", 10);
61+
for (let i = 0; i < max; i++) checkRateLimit("proj");
62+
const result = checkRateLimit("proj");
63+
expect(result).not.toBeNull();
64+
expect(result.retryAfter).toBeGreaterThan(0);
65+
});
66+
67+
it("resets counter after window expires", () => {
68+
const max = parseInt(process.env.BACKLOG_RATE_LIMIT_MAX ?? "200", 10);
69+
const windowMs = parseInt(process.env.BACKLOG_RATE_LIMIT_WINDOW ?? "60", 10) * 1000;
70+
for (let i = 0; i < max + 1; i++) checkRateLimit("proj");
71+
// Advance time past window
72+
nowMs += windowMs + 1;
73+
expect(checkRateLimit("proj")).toBeNull();
74+
});
75+
76+
it("isolates counters per slug", () => {
77+
expect(checkRateLimit("a")).toBeNull();
78+
expect(checkRateLimit("b")).toBeNull();
79+
});
80+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import { openDatabase } from "../db/schema.js";
3+
import { prepareStatements } from "../db/statements.js";
4+
import {
5+
now, requireItem, buildChecklistTree, fullItem, allSummaries, summarize,
6+
wouldCycle, requireVersion, bumpVersion, CHECKLIST_MAX_DEPTH,
7+
} from "../db/queries.js";
8+
import { NotFoundError, VersionConflictError } from "../db/errors.js";
9+
10+
function makeDb() {
11+
const db = openDatabase(":memory:");
12+
const stmts = prepareStatements(db);
13+
return { db, stmts };
14+
}
15+
16+
function createItem(stmts, title = "Test", status = "open") {
17+
const ts = now();
18+
const result = stmts.createItem.run(title, status, "", ts, ts);
19+
return stmts.getItem.get(result.lastInsertRowid);
20+
}
21+
22+
describe("now()", () => {
23+
it("returns an ISO string", () => {
24+
expect(new Date(now()).toISOString()).toBe(now());
25+
});
26+
});
27+
28+
describe("requireItem()", () => {
29+
it("returns item when found", () => {
30+
const { stmts } = makeDb();
31+
const item = createItem(stmts);
32+
expect(requireItem(stmts, item.id).id).toBe(item.id);
33+
});
34+
35+
it("throws NotFoundError when missing", () => {
36+
const { stmts } = makeDb();
37+
expect(() => requireItem(stmts, 999)).toThrow(NotFoundError);
38+
});
39+
});
40+
41+
describe("requireVersion()", () => {
42+
it("passes when version matches", () => {
43+
const { stmts } = makeDb();
44+
const item = createItem(stmts);
45+
expect(() => requireVersion(stmts, item.id, item.version)).not.toThrow();
46+
});
47+
48+
it("throws VersionConflictError when version is stale", () => {
49+
const { stmts } = makeDb();
50+
const item = createItem(stmts);
51+
expect(() => requireVersion(stmts, item.id, 99)).toThrow(VersionConflictError);
52+
});
53+
});
54+
55+
describe("bumpVersion()", () => {
56+
it("increments version", () => {
57+
const { stmts } = makeDb();
58+
const item = createItem(stmts);
59+
bumpVersion(stmts, item.id, item.version);
60+
const updated = stmts.getItem.get(item.id);
61+
expect(updated.version).toBe(item.version + 1);
62+
});
63+
64+
it("throws VersionConflictError on concurrent bump", () => {
65+
const { stmts } = makeDb();
66+
const item = createItem(stmts);
67+
bumpVersion(stmts, item.id, item.version);
68+
expect(() => bumpVersion(stmts, item.id, item.version)).toThrow(VersionConflictError);
69+
});
70+
});
71+
72+
describe("buildChecklistTree()", () => {
73+
it("returns empty array for item with no checklist", () => {
74+
const { stmts } = makeDb();
75+
const item = createItem(stmts);
76+
expect(buildChecklistTree(stmts, item.id, null)).toEqual([]);
77+
});
78+
79+
it("throws when depth exceeds max", () => {
80+
const { stmts } = makeDb();
81+
const item = createItem(stmts);
82+
// Insert checklist items directly at exactly max depth
83+
let parentId = null;
84+
for (let i = 0; i < CHECKLIST_MAX_DEPTH; i++) {
85+
const r = stmts.addChecklist.run(item.id, parentId, `level ${i}`, i);
86+
parentId = Number(r.lastInsertRowid);
87+
}
88+
// Reading this tree should throw
89+
expect(() => buildChecklistTree(stmts, item.id, null)).toThrow(/maximum allowed depth/);
90+
});
91+
});
92+
93+
describe("fullItem()", () => {
94+
it("returns item with checklist, dependencies, comments arrays", () => {
95+
const { stmts } = makeDb();
96+
const item = createItem(stmts);
97+
const full = fullItem(stmts, item.id);
98+
expect(full.checklist).toBeInstanceOf(Array);
99+
expect(full.dependencies).toBeInstanceOf(Array);
100+
expect(full.comments).toBeInstanceOf(Array);
101+
});
102+
});
103+
104+
describe("allSummaries()", () => {
105+
it("returns all items when no filter", () => {
106+
const { stmts } = makeDb();
107+
createItem(stmts, "A", "open");
108+
createItem(stmts, "B", "done");
109+
expect(allSummaries(stmts).length).toBe(2);
110+
});
111+
112+
it("filters by status", () => {
113+
const { stmts } = makeDb();
114+
createItem(stmts, "A", "open");
115+
createItem(stmts, "B", "done");
116+
expect(allSummaries(stmts, "open").length).toBe(1);
117+
});
118+
119+
it("excludes archived when includeArchived is false", () => {
120+
const { stmts } = makeDb();
121+
const item = createItem(stmts, "A", "open");
122+
stmts.updateItem.run("A", "", "archived", now(), item.id, item.version);
123+
expect(allSummaries(stmts, null, { includeArchived: false }).length).toBe(0);
124+
expect(allSummaries(stmts, null, { includeArchived: true }).length).toBe(1);
125+
});
126+
});
127+
128+
describe("wouldCycle()", () => {
129+
it("returns false when no cycle", () => {
130+
const { stmts } = makeDb();
131+
const a = createItem(stmts, "A");
132+
const b = createItem(stmts, "B");
133+
expect(wouldCycle(stmts, b.id, a.id)).toBe(false);
134+
});
135+
136+
it("returns true for direct cycle", () => {
137+
const { stmts } = makeDb();
138+
const a = createItem(stmts, "A");
139+
const b = createItem(stmts, "B");
140+
// B depends on A
141+
stmts.addDep.run(b.id, a.id);
142+
// Now check if A depending on B would cycle
143+
expect(wouldCycle(stmts, a.id, b.id)).toBe(true);
144+
});
145+
146+
it("returns true for transitive cycle", () => {
147+
const { stmts } = makeDb();
148+
const a = createItem(stmts, "A");
149+
const b = createItem(stmts, "B");
150+
const c = createItem(stmts, "C");
151+
stmts.addDep.run(b.id, a.id); // B -> A
152+
stmts.addDep.run(c.id, b.id); // C -> B
153+
// A -> C would create A -> C -> B -> A
154+
expect(wouldCycle(stmts, a.id, c.id)).toBe(true);
155+
});
156+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { SSEBroadcaster } from "../http/sse.js";
3+
4+
function mockRes() {
5+
return {
6+
writableEnded: false,
7+
writeHead: vi.fn(),
8+
write: vi.fn(),
9+
end: vi.fn(),
10+
on: vi.fn(),
11+
};
12+
}
13+
14+
describe("SSEBroadcaster", () => {
15+
it("registers a client and sends initial snapshot", () => {
16+
const sse = new SSEBroadcaster("test");
17+
const res = mockRes();
18+
sse.register("proj", res, [{ id: 1 }]);
19+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ "Content-Type": "text/event-stream" }));
20+
expect(res.write).toHaveBeenCalledWith(expect.stringContaining(JSON.stringify([{ id: 1 }])));
21+
});
22+
23+
it("broadcasts to registered clients", () => {
24+
const sse = new SSEBroadcaster("test");
25+
const res = mockRes();
26+
sse.register("proj", res, []);
27+
sse.broadcast("proj", [{ id: 2 }]);
28+
expect(res.write).toHaveBeenCalledTimes(2); // initial + broadcast
29+
const lastCall = res.write.mock.calls[1][0];
30+
expect(lastCall).toContain(JSON.stringify([{ id: 2 }]));
31+
});
32+
33+
it("skips clients with writableEnded", () => {
34+
const sse = new SSEBroadcaster("test");
35+
const res = mockRes();
36+
sse.register("proj", res, []);
37+
res.writableEnded = true;
38+
sse.broadcast("proj", []);
39+
expect(sse.clientCount("proj")).toBe(0);
40+
});
41+
42+
it("removes client on write error", () => {
43+
const sse = new SSEBroadcaster("test");
44+
const res = mockRes();
45+
sse.register("proj", res, []);
46+
// Override write to throw on the next (broadcast) call
47+
res.write.mockImplementation(() => { throw new Error("socket hang up"); });
48+
sse.broadcast("proj", []);
49+
expect(sse.clientCount("proj")).toBe(0);
50+
});
51+
52+
it("cleanup function removes the client", () => {
53+
const sse = new SSEBroadcaster("test");
54+
const res = mockRes();
55+
const cleanup = sse.register("proj", res, []);
56+
expect(sse.clientCount("proj")).toBe(1);
57+
cleanup();
58+
expect(sse.clientCount("proj")).toBe(0);
59+
});
60+
61+
it("broadcasts to multiple clients for same slug", () => {
62+
const sse = new SSEBroadcaster("test");
63+
const res1 = mockRes();
64+
const res2 = mockRes();
65+
sse.register("proj", res1, []);
66+
sse.register("proj", res2, []);
67+
sse.broadcast("proj", [{ id: 1 }]);
68+
expect(res1.write).toHaveBeenCalledTimes(2);
69+
expect(res2.write).toHaveBeenCalledTimes(2);
70+
});
71+
72+
it("does not broadcast to other slugs", () => {
73+
const sse = new SSEBroadcaster("test");
74+
const res1 = mockRes();
75+
const res2 = mockRes();
76+
sse.register("proj-a", res1, []);
77+
sse.register("proj-b", res2, []);
78+
sse.broadcast("proj-a", []);
79+
expect(res1.write).toHaveBeenCalledTimes(2); // initial + broadcast
80+
expect(res2.write).toHaveBeenCalledTimes(1); // initial only
81+
});
82+
83+
it("closeAll ends all connections and clears state", () => {
84+
const sse = new SSEBroadcaster("test");
85+
const res1 = mockRes();
86+
const res2 = mockRes();
87+
sse.register("a", res1, []);
88+
sse.register("b", res2, []);
89+
sse.closeAll();
90+
expect(res1.end).toHaveBeenCalled();
91+
expect(res2.end).toHaveBeenCalled();
92+
expect(sse.clientCount("a")).toBe(0);
93+
expect(sse.clientCount("b")).toBe(0);
94+
});
95+
96+
it("activeSlugs returns slugs with clients", () => {
97+
const sse = new SSEBroadcaster("test");
98+
sse.register("x", mockRes(), []);
99+
sse.register("y", mockRes(), []);
100+
expect([...sse.activeSlugs()]).toEqual(expect.arrayContaining(["x", "y"]));
101+
});
102+
});

0 commit comments

Comments
 (0)