backend-ts/src/logic-bug-review-2026-11-03.spec.ts
Rama main · réplica de lectura del repositorio original.
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { handleError } from './common/http-error.js';
import { formatDateTime } from './common/json.js';
import { MAX_DIFF_BYTES, GitCommitMessageService } from './file/git-commit-message.service.js';
import { GitWriteOperationService } from './file/git-write-operation.service.js';
import { WorkspaceBrowseService } from './file/workspace-browse.service.js';
import { PathSandbox } from './harness/safety/path-sandbox.js';
import {
ScheduledTaskService,
buildCronPreview,
isOneShotCron,
type ScheduledTask,
} from './schedule/scheduled-task.service.js';
import {
WATCHER_DISABLE_AFTER_FAILURES,
WatcherService,
} from './signal/watcher.service.js';
import { SignalSecretCipher } from './signal/signal-cipher.js';
import type { SignalWatcher } from './signal/types.js';
import { useTmpDirOutsideRepo } from './testing/tmp-dir.js';
import { registerLlmCallRoutes } from './usage/llm-call.routes.js';
/**
* 2026-11-03 逻辑 BUG 审查的验证用例(对应 docs/code-review/2026-11-03-logic-bug-review.md)。
*
* 每个用例按「正确行为」书写。对应缺陷已修复,这些断言是常驻回归。
*
* 原 BUG-8(numstat ` => `)/ BUG-10(truncated 误报)/ BUG-11(空文件行数)经复核
* 缺陷真实但修复价值不高,已移出报告正榜(见文档「已核查并排除」),用例同步删除。
*/
const bugIt = it;
const SECRET = 'review-secret';
// ────────────────────────────────────────────────────────────────────────────
// BUG-1 一次性任务「已入队、待执行」期间重新启用会重开档期 → 同一档跑两次
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-1 一次性任务排队待执行时被重开档期', () => {
bugIt('QUEUED 待执行期间只改 status 的保存必须保持 nextFireTime 为空', async () => {
const row: ScheduledTask = {
id: 1,
userId: 7,
agentId: 5,
sessionId: 11,
name: '年度提醒',
prompt: 'hello',
cronExpression: '0 0 9 15 8 ?',
once: 1,
status: 'ACTIVE',
fireCount: 1,
nextFireTime: null, // 已入队:executeTask 的 busy 分支清空了档期
finished: 0,
finishedAt: null,
consecutiveFailures: 0,
};
const store = {
selectById: vi.fn(async (id: number) => (id === 1 ? { ...row } : null)),
updateById: vi.fn(async (patch: Partial<ScheduledTask> & { id: number }) => {
Object.assign(row, patch);
}),
// 没有待重试;但队列里有一条尚未结算的 QUEUED run
hasPendingRetry: vi.fn(async () => false),
oldestQueuedRun: vi.fn(async () => ({ id: 9, taskId: 1, status: 'QUEUED' })),
};
const service = new ScheduledTaskService(
store as never, null as never, null as never, null as never, null as never,
null as never, null as never, null as never,
);
// 桌面/管理后台启停开关只发 { status }
await service.updateTask(1, 7, null, null, null, 'PAUSED');
await service.updateTask(1, 7, null, null, null, 'ACTIVE');
// 队内那一档还没结算,不允许再排新档期(否则 listDue 与队列会各跑一次)
expect(row.nextFireTime).toBeNull();
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-2 哨兵凭据解密失败被吞成「空请求头 / 空 body」,探针照发 → 假变化触发
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-2 哨兵凭据解密失败被静默吞掉', () => {
bugIt('解密失败必须按本轮失败处理,不得去掉鉴权头后照发探针', async () => {
// 模拟 APP_SIGNAL_SECRET 轮换/丢失:密文由旧密钥加密,服务用新密钥解
const rotated = new SignalSecretCipher('rotated-secret');
const h = makeSignalHarness({
headersJson: rotated.encrypt(JSON.stringify({ authorization: 'Bearer secret' })),
lastValueHash: 'old-hash',
});
await h.service.runCheck(1);
// 期望:解密失败 → 记一次失败并退避,不访问目标、不触发 Agent
expect(h.probeExecute).not.toHaveBeenCalled();
expect(h.openRun.run).not.toHaveBeenCalled();
expect(h.recordFailure).toHaveBeenCalledTimes(1);
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-3 5 段 cron 的一次性任务不会被识别 → 每年重复触发
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-3 5 段 cron 一次性判定失效', () => {
function fakeStore(): { store: never; inserted: ScheduledTask[] } {
const inserted: ScheduledTask[] = [];
const store = {
insert: vi.fn(async (row: ScheduledTask) => {
inserted.push(row);
return 1;
}),
};
return { store: store as never, inserted };
}
it('6 段形态(既有口径)确实被识别为一次性任务', () => {
expect(isOneShotCron('0 0 8 15 8 ?')).toBe(true);
});
bugIt('等价的 5 段形态 0 8 15 8 * 也必须识别为一次性任务', () => {
// croner 对两者都解析为「每年 8-15 08:00」,once 判定不应因书写段数而不同
expect(buildCronPreview('0 8 15 8 *').oneShot).toBe(true);
expect(isOneShotCron('0 8 15 8 *')).toBe(true);
});
bugIt('createTask 用 5 段一次性 cron 时必须置 once=1', async () => {
const { store, inserted } = fakeStore();
const service = new ScheduledTaskService(
store, null as never, null as never, null as never, null as never,
null as never, null as never, null as never,
);
const task = await service.createTask(7, 5, 11, '年度提醒', 'hello', '0 8 15 8 *');
expect(task.once).toBe(1);
expect(inserted[0]?.once).toBe(1);
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-4 createTask 接受「永远排不出下次触发」的 cron → 永久悬空的死任务
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-4 创建永不触发的定时任务', () => {
it('预览接口对 2 月 30 日明确返回 valid=false', () => {
const preview = buildCronPreview('0 0 0 30 2 *');
expect(preview.valid).toBe(false);
expect(preview.nextFireTimes).toHaveLength(0);
});
bugIt('createTask 对无法计算下次触发时间的表达式必须抛 PARAM_INVALID', async () => {
const { store } = fakeStoreForCreate();
const service = new ScheduledTaskService(
store, null as never, null as never, null as never, null as never,
null as never, null as never, null as never,
);
await expect(
service.createTask(7, 5, 11, '2 月 30 日', 'hello', '0 0 0 30 2 *'),
).rejects.toThrow();
});
});
function fakeStoreForCreate(): { store: never } {
const store = { insert: vi.fn(async () => 1) };
return { store: store as never };
}
// ────────────────────────────────────────────────────────────────────────────
// BUG-5 信号哨兵「触发失败」分支先把连败清零再 +1 → 连败永远停在 1
// ────────────────────────────────────────────────────────────────────────────
interface SignalHarness {
service: WatcherService;
row: SignalWatcher;
notifyDisabled: ReturnType<typeof vi.fn>;
openRun: { run: ReturnType<typeof vi.fn> };
probeExecute: ReturnType<typeof vi.fn>;
recordFailure: ReturnType<typeof vi.fn>;
}
/**
* 单行状态的仓储替身:markChecked / recordFailure 与 signal.repository.ts 的
* 两条 SQL 同义(markChecked 在「库内值 == 传入快照」时清零;recordFailure 对同一行 +1
* 并判停用阈值)。生产里两处操作的是同一行,替身也必须是同一行,否则会掩盖清零行为。
*/
function makeSignalHarness(overrides: Partial<SignalWatcher> = {}): SignalHarness {
const row: SignalWatcher = {
id: 1,
userId: 7,
agentId: 3,
name: 'w',
url: 'https://example.com/api',
method: 'GET',
headersJson: null,
body: null,
intervalMinutes: 15,
compareMode: 'HASH',
jsonPath: null,
promptTemplate: 'changed {diff}',
status: 'ACTIVE',
lastValueHash: 'old-hash',
lastValueSnippet: 'old',
lastCheckedAt: null,
lastChangedAt: null,
consecutiveFailures: 0,
triggerMode: 'NEW_SESSION',
reuseSessionId: null,
createdAt: null,
updatedAt: null,
...overrides,
};
let checkId = 100;
let bodySeq = 0;
const watcherRepo = {
findById: vi.fn(async (id: number) => (id === row.id ? { ...row } : null)),
findOwned: vi.fn(async (id: number, userId: number) => (
id === row.id && userId === row.userId ? { ...row } : null
)),
markChecked: vi.fn(async (
_id: number,
patch: { lastValueHash: string | null; lastValueSnippet: string | null; lastChangedAt: string | null; consecutiveFailures: number; resetFailures?: boolean },
) => {
row.lastValueHash = patch.lastValueHash;
row.lastValueSnippet = patch.lastValueSnippet;
row.lastChangedAt = patch.lastChangedAt;
row.lastCheckedAt = formatDateTime(new Date());
// 与 markChecked SQL 同义:resetFailures=false 时不碰连败(触发失败路径)
if (patch.resetFailures !== false && row.consecutiveFailures === patch.consecutiveFailures) {
row.consecutiveFailures = 0;
}
}),
recordFailure: vi.fn(async (_id: number, _backoffMinutes: number, threshold: number) => {
if (row.status !== 'ACTIVE') return null;
row.consecutiveFailures += 1;
if (row.consecutiveFailures >= threshold) row.status = 'DISABLED';
return { ...row };
}),
update: vi.fn(async (_id: number, _userId: number, patch: Partial<SignalWatcher>) => {
Object.assign(row, patch);
return true;
}),
};
const checkRepo = {
insert: vi.fn(async () => checkId++),
updateHash: vi.fn(async () => undefined),
updateValue: vi.fn(async () => undefined),
markChanged: vi.fn(async () => undefined),
markTriggered: vi.fn(async () => undefined),
markWarning: vi.fn(async () => undefined),
markError: vi.fn(async () => undefined),
countRecentTriggers: vi.fn(async () => 0),
remove: vi.fn(async () => undefined),
};
const probe = {
execute: vi.fn(async () => {
const body = `value-${bodySeq++}`;
return { ok: true, httpStatus: 200, body, bodyBytes: Buffer.from(body, 'utf8'), error: null, durationMs: 3 };
}),
};
const openRun = {
run: vi.fn(async () => {
throw new Error('预算不足,任务被拒绝');
}),
};
const notifyDisabled = vi.fn(async () => undefined);
const service = new WatcherService({
watcherRepo: watcherRepo as never,
checkRepo: checkRepo as never,
cipher: new SignalSecretCipher(SECRET),
openRun: openRun as never,
disableNotifier: { notifyWatcherDisabled: notifyDisabled } as never,
getSession: async () => null,
probe: probe as never,
});
return { service, row, notifyDisabled, openRun, probeExecute: probe.execute, recordFailure: watcherRepo.recordFailure };
}
describe('BUG-5 哨兵触发失败连败被清零', () => {
bugIt('连续 5 次触发失败后 consecutive_failures 必须累加到 5 并自动停用', async () => {
const h = makeSignalHarness();
for (let i = 0; i < WATCHER_DISABLE_AFTER_FAILURES; i += 1) {
await h.service.runCheck(1);
}
expect(h.openRun.run).toHaveBeenCalledTimes(WATCHER_DISABLE_AFTER_FAILURES);
expect(h.row.consecutiveFailures).toBe(WATCHER_DISABLE_AFTER_FAILURES);
expect(h.row.status).toBe('DISABLED');
expect(h.notifyDisabled).toHaveBeenCalledTimes(1);
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-6 哨兵停用后重新启用不重置 last_checked_at → 退避写入的未来时刻把轮询推迟 17×间隔
// ────────────────────────────────────────────────────────────────────────────
/** 复刻 signal.repository.ts:listDue 的到期判定,用来断言「启用后多久能再次轮询」。 */
function isDue(row: SignalWatcher, atMs: number): boolean {
if (row.status !== 'ACTIVE') return false;
if (row.lastCheckedAt == null) return true;
return row.lastCheckedAt < formatDateTime(new Date(atMs - row.intervalMinutes * 60_000));
}
describe('BUG-6 哨兵重新启用后仍被旧的退避时刻挡住', () => {
bugIt('从 DISABLED 切回 ACTIVE 后,一个正常间隔内必须重新到期', async () => {
// 第 5 次探针失败时 recordFailure 会写 last_checked_at = NOW() + 16×interval
const h = makeSignalHarness({
status: 'DISABLED',
consecutiveFailures: WATCHER_DISABLE_AFTER_FAILURES,
lastValueHash: 'hash-before-disable',
lastCheckedAt: '2099-01-01 00:00:00',
});
await h.service.setStatus(7, 1, 'ACTIVE');
expect(h.row.status).toBe('ACTIVE');
// 启用后等一个间隔 + 1 分钟就应再次到期(当前实现要等到 2099 年)
expect(isDue(h.row, Date.now() + (h.row.intervalMinutes + 1) * 60_000)).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-7 UTF-8 截断 off-by-one:留下孤立首字节 → 乱码 + 超出上限 → 提交信息生成失败
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-7 diff 截断在多字节字符中间时留下 U+FFFD', () => {
bugIt('截断后的 diff 必须不超上限、不含替换字符,且能通过提交信息校验', async () => {
const repo = useTmpDirOutsideRepo('mao-review-utf8-');
const fileName = 'big.txt';
const header = `--- /dev/null\n+++ b/${fileName}\n`;
// 让 2 字节字符 'é' 的首字节正好落在 MAX_DIFF_BYTES 的最后一个位置
const asciiCount = MAX_DIFF_BYTES - Buffer.byteLength(header, 'utf8') - 1;
writeFileSync(
join(repo, fileName),
Buffer.concat([Buffer.from('a'.repeat(asciiCount), 'utf8'), Buffer.from('é', 'utf8'), Buffer.from('tail', 'utf8')]),
);
const service = new GitWriteOperationService(
null as never, null as never, null as never, null as never, null as never,
);
const input = await service.buildCommitInput(repo, new Map([[
fileName,
{ path: fileName, changeType: 'CREATED', insertions: 1, deletions: 0, binary: false, untracked: true },
]]));
const diff = input.files[0]!.diff ?? '';
expect(Buffer.byteLength(diff, 'utf8')).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(diff).not.toContain('\uFFFD');
expect(input.diffBytes).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(() => new GitCommitMessageService(null as never, null as never, null as never, null as never).validateInput(input)).not.toThrow();
});
bugIt('同源副本:文件预览的 512KB 截断同样不能留下替换字符', async () => {
const ws = useTmpDirOutsideRepo('mao-review-utf8-preview-');
// 单行 512KB:512×1024−1 个 ASCII + 2 字节字符,让首字节落在截断点
writeFileSync(join(ws, 'huge.txt'), Buffer.concat([
Buffer.from('a'.repeat(512 * 1024 - 1), 'utf8'),
Buffer.from('é', 'utf8'),
]));
const service = new WorkspaceBrowseService(new PathSandbox(ws));
const result = await service.readFile(ws, 'huge.txt', 0, 5000);
const bytes = Buffer.byteLength(result.content, 'utf8');
expect(bytes).toBeLessThanOrEqual(512 * 1024);
expect(result.content).not.toContain('\uFFFD');
});
});
// ────────────────────────────────────────────────────────────────────────────
// BUG-8 用量自定义周期只校验日期形状,不校验日历 → 非法日期直达 SQL(500 / 窗口错位)
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-8 用量自定义周期接受非法日历日', () => {
bugIt('2026-02-31 必须按非法入参回落默认周期 last30,而不是透传给 service', async () => {
const summaryForUser = vi.fn(async () => ({
range: 'last30', totals: {}, byModel: [], byScene: [], byAgent: [],
}));
const app = Fastify();
app.setErrorHandler(handleError);
app.addHook('preHandler', (request, _reply, done) => {
(request as unknown as { userId: number }).userId = 7;
done();
});
registerLlmCallRoutes(app, {
llmCallService: { summaryForUser } as never,
permissionService: {} as never,
});
const res = await app.inject({
method: 'GET',
url: '/v1/llm-calls/me/summary?range=custom&startDate=2026-02-31&endDate=2026-02-31',
});
expect(res.statusCode).toBe(200);
expect(summaryForUser).toHaveBeenCalledWith(7, 'custom', undefined);
});
});