Archived
修复轮 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:
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user