cc-haha/src/server/__tests__/scheduled-tasks.test.ts
程序员阿江(Relakkes) ce92de29f0 fix: prevent transient scheduled task write failures
Cron task metadata writes can overlap closely enough that a timestamp-only
$temp filename is not stable. Harden the atomic write path by using a
collision-resistant temp name and retrying once when rename reports ENOENT.

Constraint: Scheduled task persistence must keep atomic replace semantics in ~/.claude/scheduled_tasks.json
Rejected: Add explicit file locking around every scheduled task write | more coordination overhead than the observed transient rename race warrants
Confidence: high
Scope-risk: narrow
Directive: Keep scheduled task writes collision-resistant and retriable; do not revert to timestamp-only temp file names without reproducing concurrent writes
Tested: bun test src/server/__tests__/scheduled-tasks.test.ts
Not-tested: bun test src/server/__tests__/cron-scheduler.test.ts (existing timeouts and unrelated assertion failures in this checkout)
2026-04-08 23:21:28 +08:00

372 lines
11 KiB
TypeScript

/**
* Unit tests for CronService, SearchService, and Scheduled Tasks API
*/
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { CronService } from '../services/cronService.js'
import { SearchService } from '../services/searchService.js'
// ─── Test helpers ───────────────────────────────────────────────────────────
let tmpDir: string
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
async function createTmpDir(): Promise<string> {
const dir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(dir, { recursive: true })
return dir
}
async function cleanupTmpDir(dir: string): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
} catch {
// ignore
}
}
// ─── CronService tests ─────────────────────────────────────────────────────
describe('CronService', () => {
let service: CronService
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = tmpDir
service = new CronService()
})
afterEach(async () => {
if (originalConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
await cleanupTmpDir(tmpDir)
})
it('should return empty list when no tasks file exists', async () => {
const tasks = await service.listTasks()
expect(tasks).toEqual([])
})
it('should create a task with generated id and createdAt', async () => {
const task = await service.createTask({
cron: '0 9 * * *',
prompt: 'Review commits',
recurring: true,
})
expect(task.id).toBeDefined()
expect(task.id).toHaveLength(8) // 4 bytes hex
expect(task.cron).toBe('0 9 * * *')
expect(task.prompt).toBe('Review commits')
expect(task.recurring).toBe(true)
expect(task.createdAt).toBeGreaterThan(0)
})
it('should persist tasks to file', async () => {
await service.createTask({ cron: '0 9 * * *', prompt: 'Task 1' })
await service.createTask({ cron: '30 18 * * 5', prompt: 'Task 2' })
const tasks = await service.listTasks()
expect(tasks).toHaveLength(2)
expect(tasks[0].prompt).toBe('Task 1')
expect(tasks[1].prompt).toBe('Task 2')
})
it('should update an existing task', async () => {
const created = await service.createTask({
cron: '0 9 * * *',
prompt: 'Original prompt',
})
const updated = await service.updateTask(created.id, {
prompt: 'Updated prompt',
recurring: true,
})
expect(updated.id).toBe(created.id)
expect(updated.prompt).toBe('Updated prompt')
expect(updated.recurring).toBe(true)
expect(updated.createdAt).toBe(created.createdAt)
})
it('should throw when updating a non-existent task', async () => {
await expect(
service.updateTask('nonexistent', { prompt: 'x' }),
).rejects.toThrow('Task not found')
})
it('should delete a task', async () => {
const created = await service.createTask({
cron: '0 9 * * *',
prompt: 'To delete',
})
await service.deleteTask(created.id)
const tasks = await service.listTasks()
expect(tasks).toHaveLength(0)
})
it('should throw when deleting a non-existent task', async () => {
await expect(service.deleteTask('nonexistent')).rejects.toThrow(
'Task not found',
)
})
it('should generate unique IDs', async () => {
const ids = new Set<string>()
for (let i = 0; i < 20; i++) {
const task = await service.createTask({
cron: '* * * * *',
prompt: `Task ${i}`,
})
ids.add(task.id)
}
expect(ids.size).toBe(20)
})
it('should reject create when cron or prompt is missing', async () => {
await expect(
service.createTask({ cron: '', prompt: 'something' }),
).rejects.toThrow()
await expect(
service.createTask({ cron: '* * * * *', prompt: '' }),
).rejects.toThrow()
})
it('should retry the atomic write when rename returns ENOENT', async () => {
const originalRename = fs.rename
let renameCalls = 0
const renameSpy = spyOn(fs, 'rename')
renameSpy.mockImplementation(async (...args) => {
renameCalls += 1
if (renameCalls === 1) {
const error = new Error(
'ENOENT: no such file or directory, rename tmp -> scheduled_tasks.json',
) as NodeJS.ErrnoException
error.code = 'ENOENT'
throw error
}
return originalRename(...args)
})
try {
const task = await service.createTask({
cron: '0 9 * * *',
prompt: 'Retry rename once',
})
const tasks = await service.listTasks()
expect(task.id).toBeDefined()
expect(tasks).toHaveLength(1)
expect(tasks[0]?.prompt).toBe('Retry rename once')
expect(renameCalls).toBe(2)
} finally {
renameSpy.mockRestore()
}
})
})
// ─── SearchService tests ────────────────────────────────────────────────────
describe('SearchService', () => {
let service: SearchService
let searchDir: string
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = tmpDir
service = new SearchService()
// 创建搜索用的临时文件
searchDir = path.join(tmpDir, 'workspace')
await fs.mkdir(searchDir, { recursive: true })
await fs.writeFile(
path.join(searchDir, 'hello.txt'),
'Hello World\nThis is a test\nAnother line\n',
)
await fs.writeFile(
path.join(searchDir, 'code.ts'),
'function greet() {\n return "hello"\n}\n',
)
})
afterEach(async () => {
if (originalConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
await cleanupTmpDir(tmpDir)
})
it('should find matches in workspace files', async () => {
const results = await service.searchWorkspace('Hello', { cwd: searchDir })
expect(results.length).toBeGreaterThan(0)
expect(results[0].text).toContain('Hello')
})
it('should return empty results when nothing matches', async () => {
const results = await service.searchWorkspace('ZZZZNONEXISTENT', {
cwd: searchDir,
})
expect(results).toHaveLength(0)
})
it('should respect maxResults limit', async () => {
// 写入多行匹配
const lines = Array.from({ length: 50 }, (_, i) => `match line ${i}`).join(
'\n',
)
await fs.writeFile(path.join(searchDir, 'many.txt'), lines)
const results = await service.searchWorkspace('match', {
cwd: searchDir,
maxResults: 5,
})
expect(results.length).toBeLessThanOrEqual(5)
})
it('should reject empty query', async () => {
await expect(service.searchWorkspace('')).rejects.toThrow()
})
it('should return empty session results when no projects dir exists', async () => {
const results = await service.searchSessions('test')
expect(results).toEqual([])
})
})
// ─── Scheduled Tasks API integration ────────────────────────────────────────
describe('Scheduled Tasks API', () => {
// 直接测试 handler 函数,不需要启动完整服务器
let handleScheduledTasksApi: (
req: Request,
url: URL,
segments: string[],
) => Promise<Response>
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = tmpDir
// 动态导入以获取最新的环境变量
const mod = await import('../api/scheduled-tasks.js')
handleScheduledTasksApi = mod.handleScheduledTasksApi
})
afterEach(async () => {
if (originalConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
await cleanupTmpDir(tmpDir)
})
it('should list empty tasks via GET', async () => {
const req = new Request('http://localhost/api/scheduled-tasks', {
method: 'GET',
})
const url = new URL(req.url)
const resp = await handleScheduledTasksApi(req, url, [
'api',
'scheduled-tasks',
])
const body = (await resp.json()) as { tasks: unknown[] }
expect(resp.status).toBe(200)
expect(body.tasks).toEqual([])
})
it('should create a task via POST', async () => {
const req = new Request('http://localhost/api/scheduled-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cron: '0 9 * * *',
prompt: 'Daily review',
recurring: true,
}),
})
const url = new URL(req.url)
const resp = await handleScheduledTasksApi(req, url, [
'api',
'scheduled-tasks',
])
const body = (await resp.json()) as { task: { id: string; prompt: string } }
expect(resp.status).toBe(201)
expect(body.task.id).toBeDefined()
expect(body.task.prompt).toBe('Daily review')
})
it('should CRUD a full lifecycle', async () => {
// Create
const createReq = new Request('http://localhost/api/scheduled-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cron: '0 9 * * *', prompt: 'Test task' }),
})
const createResp = await handleScheduledTasksApi(
createReq,
new URL(createReq.url),
['api', 'scheduled-tasks'],
)
const { task } = (await createResp.json()) as {
task: { id: string; prompt: string }
}
// Update
const updateReq = new Request(
`http://localhost/api/scheduled-tasks/${task.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'Updated task' }),
},
)
const updateResp = await handleScheduledTasksApi(
updateReq,
new URL(updateReq.url),
['api', 'scheduled-tasks', task.id],
)
const updated = (await updateResp.json()) as {
task: { id: string; prompt: string }
}
expect(updated.task.prompt).toBe('Updated task')
// Delete
const deleteReq = new Request(
`http://localhost/api/scheduled-tasks/${task.id}`,
{ method: 'DELETE' },
)
const deleteResp = await handleScheduledTasksApi(
deleteReq,
new URL(deleteReq.url),
['api', 'scheduled-tasks', task.id],
)
expect(deleteResp.status).toBe(200)
// Verify empty
const listReq = new Request('http://localhost/api/scheduled-tasks', {
method: 'GET',
})
const listResp = await handleScheduledTasksApi(
listReq,
new URL(listReq.url),
['api', 'scheduled-tasks'],
)
const list = (await listResp.json()) as { tasks: unknown[] }
expect(list.tasks).toHaveLength(0)
})
})