From d64f0b5ffd553ac57bd8985e99a53ca776247c75 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 02:06:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BD=AE=201(=E8=AF=84?= =?UTF-8?q?=E5=AE=A1):mail=20=E7=94=A8=E4=BE=8B=E6=B5=8B=E8=AF=95=E8=A1=A5?= =?UTF-8?q?=E9=92=89=E4=B8=89=E5=A4=84=E4=BC=AA=E8=A6=86=E7=9B=96=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=99=84=E4=BB=B6=E4=B8=8D=E9=87=8D?= =?UTF-8?q?=E5=AD=98/=E4=B8=AD=E9=80=94=E5=A4=B1=E8=B4=A5/=E5=88=86?= =?UTF-8?q?=E5=8F=91=E4=B8=8D=E5=9B=A0=E5=8D=95=E4=B8=AA=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=96=AD=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审判 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 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- apps/api/src/modules/mail/__tests__/fakes.ts | 13 ++++-- .../mail/__tests__/mail-service-poll.test.ts | 40 +++++++++++++++---- .../mail/__tests__/mail-service-send.test.ts | 1 + 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/mail/__tests__/fakes.ts b/apps/api/src/modules/mail/__tests__/fakes.ts index 56d2a5f..b2bd600 100644 --- a/apps/api/src/modules/mail/__tests__/fakes.ts +++ b/apps/api/src/modules/mail/__tests__/fakes.ts @@ -233,7 +233,12 @@ export type FakeProvider = MailProvider & { readonly sent: OutboundMessage[]; readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; 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; probeWith(report: MailProbeReport | undefined): void; }; @@ -244,6 +249,7 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider const sent: OutboundMessage[] = []; const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; let pullError: Error | undefined; + let pullFailAfter = 0; let sendError: Error | undefined; let probeReport: MailProbeReport | undefined; let uidSeq = 0; @@ -254,8 +260,9 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider queue(...messages) { pending.push(...messages); }, - failPullWith(error) { + failPullWith(error, opts = {}) { pullError = error; + pullFailAfter = opts.afterCalls ?? 0; }, failSendWith(error) { sendError = error; @@ -278,7 +285,7 @@ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider }, async pull(_accountArg, cursor, opts) { 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) => { if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; uidSeq += 1; diff --git a/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts index 095b7bb..8395a13 100644 --- a/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts +++ b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts @@ -79,7 +79,11 @@ describe('拉取', () => { h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } }))); await h.service.pollNow('acc-1'); h.imap.queue( - rawMessage('uid:v1:1', eml({ messageId: '' })), + // 撞 providerRef 的这封**带附件**:守卫在存附件之前,所以「不重存」这句话才验得出来 + // (原来两封都没附件,files.size 恒为 1,改坏实现照样绿) + rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'dup.txt', contentType: 'text/plain', content: 'y' } })), + // 注:撞 **messageId**(而 providerRef 是新的)那条路,附件会在 insert 报重复之前就存下 —— + // 留下一份没人引用的孤儿。已记欠账(要根治得给 InboundStore 加一个按 messageId 查重的口),本轮不在此坐实。 rawMessage('uid:v1:9', eml({ messageId: '' })), ); const again = await h.service.pollNow('acc-1'); @@ -100,18 +104,36 @@ describe('拉取', () => { expect(second).toMatchObject({ pulled: 1, stored: 1, more: false }); }); - it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => { - const h = harness(); - await h.accounts.save(account({ cursor: imapCursor('v1', 5) })); - h.imap.failPullWith(new Error('LOGIN pw-1234 rejected')); + it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => { + /* + * 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 —— + * 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过,见修复报告)。 + * 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动, + * "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow) + * 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。 + */ + const h = harness({ perTick: 1 }); // 每轮只吃一封,好让第一轮先真的往前挪一格 + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' })), rawMessage('uid:v1:2', eml({ messageId: '' }))); + 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'); expect(outcome.error).toBe('LOGIN *** rejected'); 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?.cursor).toEqual(imapCursor('v1', 5)); expect(saved?.pollingUntil).toBeNull(); + 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(); }); @@ -145,11 +167,13 @@ describe('分发', () => { const h = harness(); await h.accounts.save(account({ channelId: 'ch-9' })); 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('skip', async () => ({ skipped: '与我无关' })); const off = h.service.subscribe('boom', async () => { throw new Error('通道实例已停用:ch-9'); }); + h.service.subscribe('skip', async () => ({ skipped: '与我无关' })); expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', subject: '主题' }))); await h.service.pollNow('acc-1'); diff --git a/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts index 8ff25d5..8c909ce 100644 --- a/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts +++ b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts @@ -103,6 +103,7 @@ describe('send', () => { const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '', references: [''], requestedBy: { kind: 'channel', taskId: 't-1' } }); expect(result.accountId).toBe('acc-1'); expect(h.imap.sent[0]?.inReplyTo).toBe(''); + expect(h.imap.sent[0]?.references).toEqual(['']); expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); }); });