Skip to content

Commit 5879930

Browse files
feat: add comprehensive unit test suite for critical modules (#98)
* feat: add comprehensive unit test suite covering 20 modules Adds 20 new test files (383 tests total) covering all untested modules: - Validation schemas (agent, project, issue, repository) - Utility functions (format, cron helpers, claude utils) - Notification system (telegram formatting, masking, HTML conversion) - Auth system (password verification, session tokens, no-auth bypass) - LLM providers (Gemini, OpenAI, Anthropic message/tool conversion) - LLM router (config resolution, provider caching) - Agent core (builtin tools, conversation store, system prompt) - Runner infrastructure (run events pub/sub, run logging, telegram sender) - MCP client (connection lifecycle, tool dispatch, caching) - API utilities (error handler, response helpers) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: implement changes Auto-committed by pipeline — implementation phase did not commit. * ci: add GitHub Actions workflow to run tests on PRs and pushes to main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: move PLAN.md to docs/plans/ and document the convention in CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve TypeScript errors in test files after merging main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add CI check convention to CLAUDE.md — always verify CI before declaring PR ready Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve TypeScript error in pr-status.test.ts mock typing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 02e256c commit 5879930

File tree

24 files changed

+3367
-1
lines changed

24 files changed

+3367
-1
lines changed

.github/workflows/test.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: oven-sh/setup-bun@v2
17+
with:
18+
bun-version: latest
19+
20+
- name: Install dependencies
21+
run: bun install --frozen-lockfile
22+
23+
- name: Type check
24+
run: bun run tsc --noEmit
25+
26+
- name: Run tests
27+
run: bun test

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ scripts/
127127
run-agents.ts # CLI: bun run scripts/run-agents.ts [agent-name] [--project name]
128128
migrate-fs-agents.ts # One-time: migrate filesystem agents to DB
129129
install-cron.sh # Generate crontab entries from agent configs
130+
docs/
131+
plans/ # Implementation plans (date-prefixed, e.g. 2026-03-26-unit-tests.md)
130132
data/
131133
dobby.db # SQLite database (gitignored)
132134
```
@@ -204,6 +206,14 @@ FOOD_FACTS_TELEGRAM_BOT_TOKEN=... # Food facts agent bot token
204206
FOOD_FACTS_TELEGRAM_CHAT_ID=... # Food facts agent chat ID
205207
```
206208

209+
## Documentation
210+
211+
Plans and design docs go in `docs/plans/` with date-prefixed filenames (e.g. `2026-03-26-unit-tests.md`). Do NOT put plan files in the repo root.
212+
213+
## CI / Pull Requests
214+
215+
After creating or pushing to a PR, always check GitHub Actions CI status (`gh run list`, `gh run view <id> --log-failed`). If CI is failing, automatically fetch the failure logs, fix the issues, and push again. Do not tell the user the PR is ready until CI is green.
216+
207217
## Path Aliases
208218

209219
`@/*` maps to `./src/*` (configured in `tsconfig.json`).

docs/plans/2026-03-26-unit-tests.md

Lines changed: 488 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3+
import { join } from "node:path";
4+
import {
5+
BUILTIN_TOOLS,
6+
BUILTIN_TOOL_NAMES,
7+
executeBuiltinTool,
8+
} from "../builtin-tools";
9+
10+
const TEST_DIR = join(import.meta.dir, ".tmp-test-builtin-tools");
11+
12+
beforeEach(() => {
13+
mkdirSync(TEST_DIR, { recursive: true });
14+
});
15+
16+
afterEach(() => {
17+
rmSync(TEST_DIR, { recursive: true, force: true });
18+
});
19+
20+
describe("BUILTIN_TOOLS", () => {
21+
test("has exactly 5 entries", () => {
22+
expect(BUILTIN_TOOLS).toHaveLength(5);
23+
});
24+
25+
test("includes all expected tool names", () => {
26+
const names = BUILTIN_TOOLS.map((t) => t.name);
27+
expect(names).toContain("get_current_time");
28+
expect(names).toContain("list_directory");
29+
expect(names).toContain("read_file");
30+
expect(names).toContain("write_file");
31+
expect(names).toContain("get_file_info");
32+
});
33+
34+
test("each tool has name, description, and parameters", () => {
35+
for (const tool of BUILTIN_TOOLS) {
36+
expect(tool.name.length).toBeGreaterThan(0);
37+
expect(tool.description.length).toBeGreaterThan(0);
38+
expect(tool.parameters).toBeDefined();
39+
}
40+
});
41+
});
42+
43+
describe("BUILTIN_TOOL_NAMES", () => {
44+
test("matches BUILTIN_TOOLS names", () => {
45+
expect(BUILTIN_TOOL_NAMES.size).toBe(BUILTIN_TOOLS.length);
46+
for (const tool of BUILTIN_TOOLS) {
47+
expect(BUILTIN_TOOL_NAMES.has(tool.name)).toBe(true);
48+
}
49+
});
50+
});
51+
52+
describe("executeBuiltinTool", () => {
53+
describe("get_current_time", () => {
54+
test("returns parseable time JSON", async () => {
55+
const result = await executeBuiltinTool("get_current_time", {});
56+
const parsed = JSON.parse(result);
57+
expect(parsed.time).toBeDefined();
58+
expect(parsed.timezone).toBe("UTC");
59+
expect(parsed.iso).toBeDefined();
60+
});
61+
62+
test("respects timezone parameter", async () => {
63+
const result = await executeBuiltinTool("get_current_time", { timezone: "America/New_York" });
64+
const parsed = JSON.parse(result);
65+
expect(parsed.timezone).toBe("America/New_York");
66+
});
67+
68+
test("falls back to UTC on invalid timezone", async () => {
69+
const result = await executeBuiltinTool("get_current_time", { timezone: "Invalid/Zone" });
70+
const parsed = JSON.parse(result);
71+
expect(parsed.timezone).toBe("UTC");
72+
});
73+
});
74+
75+
describe("list_directory", () => {
76+
test("lists files in directory", async () => {
77+
writeFileSync(join(TEST_DIR, "file1.txt"), "hello");
78+
writeFileSync(join(TEST_DIR, "file2.txt"), "world");
79+
mkdirSync(join(TEST_DIR, "subdir"));
80+
81+
const result = await executeBuiltinTool("list_directory", { path: TEST_DIR });
82+
const parsed = JSON.parse(result);
83+
expect(parsed.count).toBe(3);
84+
const names = parsed.entries.map((e: { name: string }) => e.name);
85+
expect(names).toContain("file1.txt");
86+
expect(names).toContain("file2.txt");
87+
expect(names).toContain("subdir");
88+
});
89+
90+
test("includes file sizes", async () => {
91+
writeFileSync(join(TEST_DIR, "sized.txt"), "12345");
92+
const result = await executeBuiltinTool("list_directory", { path: TEST_DIR });
93+
const parsed = JSON.parse(result);
94+
const file = parsed.entries.find((e: { name: string }) => e.name === "sized.txt");
95+
expect(file.type).toBe("file");
96+
expect(file.size).toBe(5);
97+
});
98+
99+
test("returns error for nonexistent directory", async () => {
100+
const result = await executeBuiltinTool("list_directory", { path: "/nonexistent-dir-xyz" });
101+
const parsed = JSON.parse(result);
102+
expect(parsed.error).toBeDefined();
103+
});
104+
});
105+
106+
describe("read_file", () => {
107+
test("reads file content", async () => {
108+
const filePath = join(TEST_DIR, "test.txt");
109+
writeFileSync(filePath, "Hello, World!");
110+
const result = await executeBuiltinTool("read_file", { path: filePath });
111+
const parsed = JSON.parse(result);
112+
expect(parsed.content).toBe("Hello, World!");
113+
expect(parsed.size).toBe(13);
114+
expect(parsed.truncated).toBe(false);
115+
});
116+
117+
test("truncates with maxBytes", async () => {
118+
const filePath = join(TEST_DIR, "big.txt");
119+
writeFileSync(filePath, "Hello, World!");
120+
const result = await executeBuiltinTool("read_file", { path: filePath, maxBytes: 5 });
121+
const parsed = JSON.parse(result);
122+
expect(parsed.content).toBe("Hello");
123+
expect(parsed.truncated).toBe(true);
124+
});
125+
126+
test("returns error for nonexistent file", async () => {
127+
const result = await executeBuiltinTool("read_file", { path: "/nonexistent-file-xyz" });
128+
const parsed = JSON.parse(result);
129+
expect(parsed.error).toBeDefined();
130+
});
131+
});
132+
133+
describe("write_file", () => {
134+
test("writes file content", async () => {
135+
const filePath = join(TEST_DIR, "output.txt");
136+
const result = await executeBuiltinTool("write_file", { path: filePath, content: "written!" });
137+
const parsed = JSON.parse(result);
138+
expect(parsed.written).toBe(true);
139+
expect(parsed.size).toBe(8);
140+
});
141+
142+
test("creates parent directories", async () => {
143+
const filePath = join(TEST_DIR, "deep", "nested", "dir", "file.txt");
144+
const result = await executeBuiltinTool("write_file", { path: filePath, content: "deep" });
145+
const parsed = JSON.parse(result);
146+
expect(parsed.written).toBe(true);
147+
});
148+
});
149+
150+
describe("get_file_info", () => {
151+
test("returns metadata for file", async () => {
152+
const filePath = join(TEST_DIR, "info.txt");
153+
writeFileSync(filePath, "test content");
154+
const result = await executeBuiltinTool("get_file_info", { path: filePath });
155+
const parsed = JSON.parse(result);
156+
expect(parsed.type).toBe("file");
157+
expect(parsed.size).toBe(12);
158+
expect(parsed.created).toBeDefined();
159+
expect(parsed.modified).toBeDefined();
160+
expect(parsed.permissions).toBeDefined();
161+
});
162+
163+
test("returns metadata for directory", async () => {
164+
const result = await executeBuiltinTool("get_file_info", { path: TEST_DIR });
165+
const parsed = JSON.parse(result);
166+
expect(parsed.type).toBe("directory");
167+
});
168+
169+
test("returns error for nonexistent path", async () => {
170+
const result = await executeBuiltinTool("get_file_info", { path: "/nonexistent-xyz" });
171+
const parsed = JSON.parse(result);
172+
expect(parsed.error).toBeDefined();
173+
});
174+
});
175+
176+
describe("unknown tool", () => {
177+
test("returns error for unknown tool name", async () => {
178+
const result = await executeBuiltinTool("unknown_tool", {});
179+
const parsed = JSON.parse(result);
180+
expect(parsed.error).toContain("Unknown built-in tool");
181+
});
182+
});
183+
});

0 commit comments

Comments
 (0)