修复轮 1(评审):mail 用例测试补钉三处伪覆盖 —— 附件不重存/中途失败/分发不因单个失败断链

评审判 Needs fixes:三条断言此前恒真,改坏实现照样绿。①重复拉到里撞 providerRef 的
那封改带附件,才验得出「守卫在存附件之前生效」;②拉取抛错改为两轮 pollNow ——
第一轮先用 afterCalls 让 pull() 真正成功推进游标,第二轮才炸,不再是「零封被处理,
游标没动只因为它压根没机会动」;③分发失败的订阅方 boom 从最后一个挪到中间(ok →
boom → skip),证「继续往下投」而不是恰好排在末尾。send 测试补 references 断言。
fakes.ts 的 failPullWith 加 afterCalls 支持第 N 次调用起报错。逐条用临时注入的
实现缺陷验证测试确实会红(验证后已还原,mail-service.ts 净差异为零)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 02:06:42 +08:00
co-authored by Claude Fable 5
parent 0c6e8e2c1b
commit d64f0b5ffd
3 changed files with 43 additions and 11 deletions
+10 -3
View File
@@ -233,7 +233,12 @@ export type FakeProvider = MailProvider & {
readonly sent: OutboundMessage[]; readonly sent: OutboundMessage[];
readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>;
queue(...messages: RawMessage[]): void; queue(...messages: RawMessage[]): void;
failPullWith(error: Error | undefined): void; /**
* 让 `pull` 从第几次调用开始报错(1-based 调用计数;`afterCalls` 缺省 0 = 第一次调用就炸,
* 与既有用例的期望一致)。**中途失败**才验得出「游标停在最后一封成功处」——
* 若失败恒发生在第一次调用,游标"没动"只是因为它压根没机会动,测不出逐封推进的行为。
*/
failPullWith(error: Error | undefined, opts?: { readonly afterCalls?: number }): void;
failSendWith(error: Error | undefined): void; failSendWith(error: Error | undefined): void;
probeWith(report: MailProbeReport | undefined): void; probeWith(report: MailProbeReport | undefined): void;
}; };
@@ -244,6 +249,7 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider
const sent: OutboundMessage[] = []; const sent: OutboundMessage[] = [];
const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; const pullCalls: Array<{ cursor: MailCursor; limit: number }> = [];
let pullError: Error | undefined; let pullError: Error | undefined;
let pullFailAfter = 0;
let sendError: Error | undefined; let sendError: Error | undefined;
let probeReport: MailProbeReport | undefined; let probeReport: MailProbeReport | undefined;
let uidSeq = 0; let uidSeq = 0;
@@ -254,8 +260,9 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider
queue(...messages) { queue(...messages) {
pending.push(...messages); pending.push(...messages);
}, },
failPullWith(error) { failPullWith(error, opts = {}) {
pullError = error; pullError = error;
pullFailAfter = opts.afterCalls ?? 0;
}, },
failSendWith(error) { failSendWith(error) {
sendError = error; sendError = error;
@@ -278,7 +285,7 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider
}, },
async pull(_accountArg, cursor, opts) { async pull(_accountArg, cursor, opts) {
pullCalls.push({ cursor, limit: opts.limit }); pullCalls.push({ cursor, limit: opts.limit });
if (pullError !== undefined) throw pullError; if (pullError !== undefined && pullCalls.length > pullFailAfter) throw pullError;
const batch = pending.splice(0, opts.limit).map((m) => { const batch = pending.splice(0, opts.limit).map((m) => {
if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m;
uidSeq += 1; uidSeq += 1;
@@ -79,7 +79,11 @@ describe('拉取', () => {
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } }))); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } })));
await h.service.pollNow('acc-1'); await h.service.pollNow('acc-1');
h.imap.queue( h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>' })), // 撞 providerRef 的这封**带附件**:守卫在存附件之前,所以「不重存」这句话才验得出来
// (原来两封都没附件,files.size 恒为 1,改坏实现照样绿)
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'dup.txt', contentType: 'text/plain', content: 'y' } })),
// 注:撞 **messageId**(而 providerRef 是新的)那条路,附件会在 insert 报重复之前就存下 ——
// 留下一份没人引用的孤儿。已记欠账(要根治得给 InboundStore 加一个按 messageId 查重的口),本轮不在此坐实。
rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })), rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
); );
const again = await h.service.pollNow('acc-1'); const again = await h.service.pollNow('acc-1');
@@ -100,18 +104,36 @@ describe('拉取', () => {
expect(second).toMatchObject({ pulled: 1, stored: 1, more: false }); expect(second).toMatchObject({ pulled: 1, stored: 1, more: false });
}); });
it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => { it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => {
const h = harness(); /*
await h.accounts.save(account({ cursor: imapCursor('v1', 5) })); * 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 ——
h.imap.failPullWith(new Error('LOGIN pw-1234 rejected')); * 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过,见修复报告)。
* 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动,
* "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow)
* 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。
*/
const h = harness({ perTick: 1 }); // 每轮只吃一封,好让第一轮先真的往前挪一格
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })), rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
h.imap.failPullWith(new Error('LOGIN pw-1234 rejected'), { afterCalls: 1 });
const first = await h.service.pollNow('acc-1');
expect(first).toMatchObject({ stored: 1, more: true });
expect(first.error).toBeUndefined();
expect(h.accounts.rows.get('acc-1')?.cursor).toEqual(imapCursor('v1', 1)); // 第一轮真的挪到了 uid 1
const outcome = await h.service.pollNow('acc-1'); const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toBe('LOGIN *** rejected'); expect(outcome.error).toBe('LOGIN *** rejected');
const saved = h.accounts.rows.get('acc-1'); const saved = h.accounts.rows.get('acc-1');
// **停在最后一封成功处**(上一轮推进到的 uid 1),不是"从未挪动过"
expect(saved?.cursor).toEqual(imapCursor('v1', 1));
expect(saved?.lastPollError).toBe('LOGIN *** rejected'); expect(saved?.lastPollError).toBe('LOGIN *** rejected');
expect(saved?.cursor).toEqual(imapCursor('v1', 5));
expect(saved?.pollingUntil).toBeNull(); expect(saved?.pollingUntil).toBeNull();
h.imap.failPullWith(undefined); h.imap.failPullWith(undefined);
expect((await h.service.pollNow('acc-1')).error).toBeUndefined(); const third = await h.service.pollNow('acc-1');
expect(third.error).toBeUndefined();
expect(third.stored).toBe(1); // 第二封这才真正拉到
expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull(); expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull();
}); });
@@ -145,11 +167,13 @@ describe('分发', () => {
const h = harness(); const h = harness();
await h.accounts.save(account({ channelId: 'ch-9' })); await h.accounts.save(account({ channelId: 'ch-9' }));
const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = []; const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = [];
// 注册顺序 ok → boom → skip:出错的 boom **不在最后一个**,后面还有 skip ——
// 若 dispatch 遇错就 break,skip 会被漏投,连它那条留痕都不会存在(而不只是状态不对)
h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId })); h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId }));
h.service.subscribe('skip', async () => ({ skipped: '与我无关' }));
const off = h.service.subscribe('boom', async () => { const off = h.service.subscribe('boom', async () => {
throw new Error('通道实例已停用:ch-9'); throw new Error('通道实例已停用:ch-9');
}); });
h.service.subscribe('skip', async () => ({ skipped: '与我无关' }));
expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError); expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError);
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', subject: '主题' }))); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', subject: '主题' })));
await h.service.pollNow('acc-1'); await h.service.pollNow('acc-1');
@@ -103,6 +103,7 @@ describe('send', () => {
const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } }); const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
expect(result.accountId).toBe('acc-1'); expect(result.accountId).toBe('acc-1');
expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>'); expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
expect(h.imap.sent[0]?.references).toEqual(['<m1@acme.com>']);
expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' });
}); });
}); });