终审修复轮补测:翻页上限与 lowestUid 各钉一条(P1-1 / P1-2 原本只改了实现没有测试)

- wecom 翻页上限:服务端一直回 has_more:1 时不再死循环 —— 到顶交出手上这些、more=true、游标原样退回。
  单请求 30s 超时护不住它:每一页都是成功返回,超时永远不触发。
- imap 回填锚点的取最小抽成纯函数 `lowestUid`(原先内联在 withImap 里,测不着):
  测里拿二十万个 UID 反证 `Math.min(...uids)` 真的抛 RangeError,而 reduce 那一版稳。
- 顺手清掉 mail-service-poll.test.ts 里最后一处指向「修复报告」的注释(语境会丢,P1-6 同款)。

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 03:49:57 +08:00
co-authored by Claude Fable 5
parent 816389a185
commit 58b1111e7e
4 changed files with 53 additions and 4 deletions
@@ -1,6 +1,6 @@
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
import { describe, expect, it } from 'vitest';
import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
import { imapSettingsOf, internalDateOf, lowestUid, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } };
@@ -42,6 +42,17 @@ describe('orderFetched', () => {
});
});
describe('lowestUid', () => {
it('取最小;空结果回兜底;**十万级不栈溢出**(Math.min(...uids) 会)', () => {
expect(lowestUid([12, 7, 30], 99)).toBe(7);
expect(lowestUid([], 99)).toBe(99);
expect(lowestUid([100], 3)).toBe(3); // 兜底比数组里的都小时,兜底赢(它是 uidNext,只在空集时才该赢)
const many = Array.from({ length: 200_000 }, (_, i) => i + 5);
expect(() => Math.min(...many)).toThrow(RangeError); // 反证:铺参数那一手在这个量级真的炸
expect(lowestUid(many, Number.MAX_SAFE_INTEGER)).toBe(5);
});
});
describe('mailOptionsOf', () => {
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
const options = mailOptionsOf({
@@ -120,7 +120,7 @@ describe('拉取', () => {
it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => {
/*
* 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 ——
* 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过,见修复报告)。
* 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过)。
* 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动,
* "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow)
* 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。
@@ -181,6 +181,34 @@ describe('pull', () => {
expect(result.cursor).toEqual(cursor);
});
it('**翻页有上限**:服务端一直回 has_more:1 也不死循环 —— 到顶当本轮吃不完,游标不推进', async () => {
/*
* 单请求 30s 超时护不住这一条:每一页都是**成功返回**,超时永远不触发。
* 到顶就交出手上这些、报 more=true、游标原样退回,下一轮接着翻,一封都不丢。
*/
let pages = 0;
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
// 每页都说「还有」,而一封新信都不给(极端形:cursor 一直动、内容全被 known 滤掉)
'get_mail_list': () => {
pages += 1;
return { errcode: 0, next_cursor: `c${pages}`, has_more: 1, mail_list: [{ mail_id: `known-${pages}` }] };
},
});
const cursor = wecomCursor(1);
const result = await createWecomProvider({ fetch }).pull(account, cursor, {
limit: 50,
now,
backfillFrom: null,
known: async (refs) => new Set(refs), // 全都已入库
});
expect(pages).toBeLessThanOrEqual(50);
expect(pages).toBeGreaterThan(1);
expect(result.messages).toEqual([]);
expect(result.more).toBe(true);
expect(result.cursor).toEqual(cursor);
});
it('取满 limit 就停,more=true', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
@@ -71,6 +71,17 @@ export function mailOptionsOf(message: OutboundMessage): Mail.Options {
};
}
/**
* 回填锚点要的是**最小的那个 UID**。
*
* `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(整个数组铺成实参),
* 而回填正是「一次搜出一大把」的那条路。reduce 不铺参数,搜多少都稳。
* 空结果回 `fallback`(uidNext:什么都没有就锚在下一封之前)。
*/
export function lowestUid(uids: readonly number[], fallback: number): number {
return uids.reduce((low, uid) => (uid < low ? uid : low), fallback);
}
function transporterOf(account: ProviderAccount) {
const { settings, login, password } = imapSettingsOf(account);
return nodemailer.createTransport({
@@ -154,8 +165,7 @@ export function createImapSmtpProvider(): MailProvider {
const box = mailboxOf(client);
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
const uids = await client.search({ since: backfillFrom }, { uid: true });
// `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(展开成十万个实参);reduce 不铺参数
const first = uids === false || uids.length === 0 ? box.uidNext : uids.reduce((a, b) => (b < a ? b : a), uids[0] ?? box.uidNext);
const first = lowestUid(uids === false ? [] : uids, box.uidNext);
return imapCursor(box.uidValidity, first - 1);
} finally {
lock.release();