Skip to content

Commit ab39f25

Browse files
chapterjasonclaude
andcommitted
test: add coverage tests for local store, schemas, logger, SSE, remote store, and auth
Fills coverage gaps across all targeted files: - local-store: close(), searchItems with status/includeArchived branches, removeDependency non-existent - schemas: validate() success and error paths including empty-path fallback - logger: info/warn/error/debug/trace writes, inode detection, rotation, fstatSync error catches, writeSync error catch - sse: cleanup when slug no longer in clients map (optional chaining branch) - remote-store: 409/404 body field fallbacks, searchItems/listItems with includeArchived=false, json() catch on non-ok - auth: loadApiKeys when file missing or has invalid JSON, migrate-and-continue path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b60cbf commit ab39f25

6 files changed

Lines changed: 422 additions & 0 deletions

File tree

packages/api/src/__tests__/auth.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,34 @@ describe("checkRateLimit()", () => {
7979
});
8080
});
8181

82+
// ---- loadApiKeys ----
83+
describe("loadApiKeys()", () => {
84+
beforeEach(() => { vi.resetModules(); });
85+
afterEach(() => { vi.restoreAllMocks(); });
86+
87+
it("returns {} when keys file does not exist", async () => {
88+
vi.doMock("fs", async (importOriginal) => {
89+
const real = await importOriginal();
90+
return { ...real, existsSync: () => false };
91+
});
92+
const { loadApiKeys } = await import("../auth/auth.js");
93+
expect(loadApiKeys()).toEqual({});
94+
});
95+
96+
it("returns {} when keys file has invalid JSON", async () => {
97+
vi.doMock("fs", async (importOriginal) => {
98+
const real = await importOriginal();
99+
return {
100+
...real,
101+
existsSync: () => true,
102+
readFileSync: () => "not-json{{{",
103+
};
104+
});
105+
const { loadApiKeys } = await import("../auth/auth.js");
106+
expect(loadApiKeys()).toEqual({});
107+
});
108+
});
109+
82110
// ---- authenticate ----
83111
describe("authenticate()", () => {
84112
beforeEach(() => {
@@ -127,6 +155,34 @@ describe("authenticate()", () => {
127155
expect(slug).toBe("my-project");
128156
});
129157

158+
it("migrates a non-matching plaintext key and continues searching", async () => {
159+
const writeMock = vi.fn();
160+
vi.doMock("fs", async (importOriginal) => {
161+
const real = await importOriginal();
162+
const otherPlaintextKey = "sk-proj-otherkey";
163+
const targetKey = "sk-proj-targetkey";
164+
const { createHash } = await import("crypto");
165+
const targetHash = createHash("sha256").update(targetKey).digest("hex");
166+
// Two entries: one plaintext non-matching, one already-hashed matching
167+
const keys = {
168+
[otherPlaintextKey]: { slug: "other-project", created: "2024-01-01" },
169+
[targetHash]: { slug: "target-project", created: "2024-01-01" },
170+
};
171+
return {
172+
...real,
173+
existsSync: () => true,
174+
readFileSync: () => JSON.stringify(keys),
175+
mkdirSync: vi.fn(),
176+
writeFileSync: writeMock,
177+
};
178+
});
179+
const { authenticate } = await import("../auth/auth.js");
180+
const slug = authenticate({ headers: { authorization: "Bearer sk-proj-targetkey" } });
181+
expect(slug).toBe("target-project");
182+
// writeFileSync should have been called due to migration of otherPlaintextKey
183+
expect(writeMock).toHaveBeenCalled();
184+
});
185+
130186
it("auto-migrates plaintext key and returns slug", async () => {
131187
const writeMock = vi.fn();
132188
vi.doMock("fs", async (importOriginal) => {

packages/core/src/__tests__/local-store.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,50 @@ describe("LocalStore — comments", () => {
169169
});
170170
});
171171

172+
describe("LocalStore — close()", () => {
173+
it("closes the database without error", () => {
174+
const store = makeStore();
175+
expect(() => store.close()).not.toThrow();
176+
});
177+
});
178+
179+
describe("LocalStore — searchItems includeArchived", () => {
180+
it("returns archived items when includeArchived=true (default)", () => {
181+
const store = makeStore();
182+
const item = store.createItem({ title: "Alpha thing" });
183+
// Soft-archive by setting status to 'archived' (MCP soft-delete)
184+
store.updateItem(item.id, { version: 1, status: "archived" });
185+
const results = store.searchItems("thing", null, { includeArchived: true });
186+
expect(results.length).toBe(1);
187+
});
188+
189+
it("excludes archived items when includeArchived=false", () => {
190+
const store = makeStore();
191+
const item = store.createItem({ title: "Beta thing" });
192+
store.updateItem(item.id, { version: 1, status: "archived" });
193+
const results = store.searchItems("thing", null, { includeArchived: false });
194+
expect(results.length).toBe(0);
195+
});
196+
197+
it("filters by status when status is provided", () => {
198+
const store = makeStore();
199+
store.createItem({ title: "Gamma thing" });
200+
const results = store.searchItems("thing", "open");
201+
expect(results.length).toBe(1);
202+
expect(results[0].status).toBe("open");
203+
});
204+
});
205+
206+
describe("LocalStore — removeDependency nonexistent", () => {
207+
it("throws when dependency does not exist", () => {
208+
const store = makeStore();
209+
const a = store.createItem({ title: "A" });
210+
const b = store.createItem({ title: "B" });
211+
expect(() => store.removeDependency(b.id, { version: 1, depends_on_id: a.id }))
212+
.toThrow(/does not exist/);
213+
});
214+
});
215+
172216
describe("LocalStore — E2E: create → checklist → dependency → conflict → retry", () => {
173217
it("full workflow", () => {
174218
const store = makeStore();
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
2+
import { mkdtempSync, readFileSync, existsSync, writeFileSync } from "fs";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
5+
import { rmSync } from "fs";
6+
7+
let logDir;
8+
let logger;
9+
10+
describe("logger — basic writes", () => {
11+
beforeAll(async () => {
12+
logDir = mkdtempSync(join(tmpdir(), "backlog-logger-test-"));
13+
vi.resetModules();
14+
process.env.BACKLOG_LOG_DIR = logDir;
15+
process.env.LOG_LEVEL = "trace";
16+
({ logger } = await import("../logger.js"));
17+
});
18+
19+
afterAll(() => {
20+
delete process.env.BACKLOG_LOG_DIR;
21+
delete process.env.LOG_LEVEL;
22+
try { rmSync(logDir, { recursive: true, force: true }); } catch (_) {}
23+
});
24+
25+
it("logger.info writes a JSON line to the log file", () => {
26+
logger.info("test-info-message", { key: "value" });
27+
const logFile = join(logDir, "agent-backlog.log");
28+
expect(existsSync(logFile)).toBe(true);
29+
const content = readFileSync(logFile, "utf8");
30+
expect(content).toContain("test-info-message");
31+
expect(content).toContain('"level":"info"');
32+
});
33+
34+
it("logger.warn writes a JSON line", () => {
35+
logger.warn("test-warn-message");
36+
const content = readFileSync(join(logDir, "agent-backlog.log"), "utf8");
37+
expect(content).toContain("test-warn-message");
38+
});
39+
40+
it("logger.error writes a JSON line", () => {
41+
logger.error("test-error-message");
42+
const content = readFileSync(join(logDir, "agent-backlog.log"), "utf8");
43+
expect(content).toContain("test-error-message");
44+
});
45+
46+
it("logger.debug writes a JSON line (level=trace allows it)", () => {
47+
logger.debug("test-debug-message");
48+
const content = readFileSync(join(logDir, "agent-backlog.log"), "utf8");
49+
expect(content).toContain("test-debug-message");
50+
});
51+
52+
it("logger.trace writes a JSON line (level=trace allows it)", () => {
53+
logger.trace("test-trace-message");
54+
const content = readFileSync(join(logDir, "agent-backlog.log"), "utf8");
55+
expect(content).toContain("test-trace-message");
56+
});
57+
});
58+
59+
describe("logger — inode detection (checkInode)", () => {
60+
it("reopens log file when inode changes (external rotation simulation)", async () => {
61+
const inodeDir = mkdtempSync(join(tmpdir(), "backlog-logger-inode-"));
62+
vi.resetModules();
63+
process.env.BACKLOG_LOG_DIR = inodeDir;
64+
process.env.LOG_LEVEL = "info";
65+
66+
const { logger: inodeLogger } = await import("../logger.js");
67+
68+
const logFile = join(inodeDir, "agent-backlog.log");
69+
inodeLogger.info("before-rotation");
70+
expect(existsSync(logFile)).toBe(true);
71+
72+
// Simulate external rotation: rename the current log file
73+
const { renameSync } = await import("fs");
74+
renameSync(logFile, `${logFile}.old`);
75+
76+
// Next write should detect inode mismatch and reopen (creates new file)
77+
inodeLogger.info("after-rotation");
78+
expect(existsSync(logFile)).toBe(true);
79+
80+
delete process.env.BACKLOG_LOG_DIR;
81+
delete process.env.LOG_LEVEL;
82+
try { rmSync(inodeDir, { recursive: true, force: true }); } catch (_) {}
83+
});
84+
});
85+
86+
describe("logger — rotation (rotate)", () => {
87+
it("rotates log file when it exceeds LOG_MAX_FILE_BYTES", async () => {
88+
const rotDir = mkdtempSync(join(tmpdir(), "backlog-logger-rotate-"));
89+
vi.resetModules();
90+
process.env.BACKLOG_LOG_DIR = rotDir;
91+
process.env.LOG_LEVEL = "info";
92+
93+
// Mock config to set a 1-byte max so rotation triggers immediately
94+
vi.doMock("../config/config.js", async (importOriginal) => {
95+
const real = await importOriginal();
96+
return { ...real, LOG_MAX_FILE_BYTES: 1 };
97+
});
98+
99+
const { logger: rotLogger } = await import("../logger.js");
100+
const logFile = join(rotDir, "agent-backlog.log");
101+
102+
rotLogger.info("first-write-opens-file");
103+
expect(existsSync(logFile)).toBe(true);
104+
105+
// Second write: file is now >= 1 byte, so rotation triggers
106+
rotLogger.info("second-write-triggers-rotation");
107+
108+
expect(existsSync(`${logFile}.1`)).toBe(true);
109+
110+
delete process.env.BACKLOG_LOG_DIR;
111+
delete process.env.LOG_LEVEL;
112+
vi.restoreAllMocks();
113+
try { rmSync(rotDir, { recursive: true, force: true }); } catch (_) {}
114+
});
115+
});
116+
117+
describe("logger — checkInode fstatSync error (outer catch)", () => {
118+
it("handles fstatSync throwing in checkInode", async () => {
119+
const catchDir = mkdtempSync(join(tmpdir(), "backlog-logger-catch-"));
120+
vi.resetModules();
121+
process.env.BACKLOG_LOG_DIR = catchDir;
122+
process.env.LOG_LEVEL = "info";
123+
124+
let fstatCallCount = 0;
125+
vi.doMock("fs", async (importOriginal) => {
126+
const real = await importOriginal();
127+
return {
128+
...real,
129+
fstatSync: (...args) => {
130+
fstatCallCount++;
131+
// write() calls checkInode then rotate, each calls fstatSync once.
132+
// First write: checkInode=call1, rotate=call2
133+
// Second write: checkInode=call3 <- throw here to hit outer catch
134+
if (fstatCallCount === 3) throw new Error("mock-fstat-failure");
135+
return real.fstatSync(...args);
136+
},
137+
};
138+
});
139+
140+
const { logger: catchLogger } = await import("../logger.js");
141+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
142+
143+
catchLogger.info("first-opens-file");
144+
// second write: checkInode's fstatSync will throw, triggering outer catch
145+
catchLogger.info("second-triggers-inode-check");
146+
147+
expect(stderrSpy).toHaveBeenCalledWith("logger:inode-check-error", "mock-fstat-failure");
148+
149+
stderrSpy.mockRestore();
150+
delete process.env.BACKLOG_LOG_DIR;
151+
delete process.env.LOG_LEVEL;
152+
vi.restoreAllMocks();
153+
try { rmSync(catchDir, { recursive: true, force: true }); } catch (_) {}
154+
});
155+
});
156+
157+
describe("logger — rotate fstatSync error (rotate catch)", () => {
158+
it("handles fstatSync throwing in rotate", async () => {
159+
const rotCatchDir = mkdtempSync(join(tmpdir(), "backlog-logger-rotcatch-"));
160+
vi.resetModules();
161+
process.env.BACKLOG_LOG_DIR = rotCatchDir;
162+
process.env.LOG_LEVEL = "info";
163+
164+
let fstatCallCount = 0;
165+
vi.doMock("fs", async (importOriginal) => {
166+
const real = await importOriginal();
167+
return {
168+
...real,
169+
fstatSync: (...args) => {
170+
fstatCallCount++;
171+
// write() calls checkInode then rotate, each calls fstatSync once.
172+
// First write: checkInode=call1, rotate=call2
173+
// Second write: checkInode=call3, rotate=call4 <- throw here
174+
if (fstatCallCount === 4) throw new Error("mock-rotate-fstat-failure");
175+
return real.fstatSync(...args);
176+
},
177+
};
178+
});
179+
180+
const { logger: rotCatchLogger } = await import("../logger.js");
181+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
182+
183+
rotCatchLogger.info("first-opens-file");
184+
// second write triggers rotate, which calls fstatSync and throws
185+
rotCatchLogger.info("second-triggers-rotate-error");
186+
187+
expect(stderrSpy).toHaveBeenCalledWith("logger:fstat-error", "mock-rotate-fstat-failure");
188+
189+
stderrSpy.mockRestore();
190+
delete process.env.BACKLOG_LOG_DIR;
191+
delete process.env.LOG_LEVEL;
192+
vi.restoreAllMocks();
193+
try { rmSync(rotCatchDir, { recursive: true, force: true }); } catch (_) {}
194+
});
195+
});
196+
197+
describe("logger — write error catch", () => {
198+
it("logs to stderr instead of throwing when writeSync fails", async () => {
199+
const errDir = mkdtempSync(join(tmpdir(), "backlog-logger-err-"));
200+
vi.resetModules();
201+
process.env.BACKLOG_LOG_DIR = errDir;
202+
process.env.LOG_LEVEL = "info";
203+
204+
// Mock fs to make writeSync throw
205+
vi.doMock("fs", async (importOriginal) => {
206+
const real = await importOriginal();
207+
return {
208+
...real,
209+
writeSync: () => { throw new Error("mock-write-failure"); },
210+
};
211+
});
212+
213+
const { logger: errLogger } = await import("../logger.js");
214+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
215+
216+
// Should not throw; error is caught and logged to stderr
217+
expect(() => errLogger.info("write-will-fail")).not.toThrow();
218+
expect(stderrSpy).toHaveBeenCalledWith("logger:write-error", "mock-write-failure");
219+
220+
stderrSpy.mockRestore();
221+
delete process.env.BACKLOG_LOG_DIR;
222+
delete process.env.LOG_LEVEL;
223+
vi.restoreAllMocks();
224+
try { rmSync(errDir, { recursive: true, force: true }); } catch (_) {}
225+
});
226+
});

0 commit comments

Comments
 (0)