Archived
终审修复轮补测:翻页上限与 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:
@@ -1,6 +1,6 @@
|
|||||||
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
|
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
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' } };
|
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', () => {
|
describe('mailOptionsOf', () => {
|
||||||
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
|
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
|
||||||
const options = mailOptionsOf({
|
const options = mailOptionsOf({
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('拉取', () => {
|
|||||||
it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => {
|
it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => {
|
||||||
/*
|
/*
|
||||||
* 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 ——
|
* 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 ——
|
||||||
* 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过,见修复报告)。
|
* 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过)。
|
||||||
* 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动,
|
* 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动,
|
||||||
* "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow)
|
* "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow)
|
||||||
* 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。
|
* 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。
|
||||||
|
|||||||
@@ -181,6 +181,34 @@ describe('pull', () => {
|
|||||||
expect(result.cursor).toEqual(cursor);
|
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 () => {
|
it('取满 limit 就停,more=true', async () => {
|
||||||
const { fetch } = scripted({
|
const { fetch } = scripted({
|
||||||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
|
'/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) {
|
function transporterOf(account: ProviderAccount) {
|
||||||
const { settings, login, password } = imapSettingsOf(account);
|
const { settings, login, password } = imapSettingsOf(account);
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
@@ -154,8 +165,7 @@ export function createImapSmtpProvider(): MailProvider {
|
|||||||
const box = mailboxOf(client);
|
const box = mailboxOf(client);
|
||||||
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
|
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
|
||||||
const uids = await client.search({ since: backfillFrom }, { uid: true });
|
const uids = await client.search({ since: backfillFrom }, { uid: true });
|
||||||
// `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(展开成十万个实参);reduce 不铺参数
|
const first = lowestUid(uids === false ? [] : uids, box.uidNext);
|
||||||
const first = uids === false || uids.length === 0 ? box.uidNext : uids.reduce((a, b) => (b < a ? b : a), uids[0] ?? box.uidNext);
|
|
||||||
return imapCursor(box.uidValidity, first - 1);
|
return imapCursor(box.uidValidity, first - 1);
|
||||||
} finally {
|
} finally {
|
||||||
lock.release();
|
lock.release();
|
||||||
|
|||||||
Reference in New Issue
Block a user