修复轮1:凭证解不开时明文经 SyntaxError 泄进 last_poll_error;同键在飞的那封会被当失败行重发;发成功不清上一次的错误话;卡死的 dispatching 给人手一条路

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 01:29:24 +08:00
co-authored by Claude Fable 5
parent 79fd25e1c1
commit 78e36ab73c
4 changed files with 239 additions and 5 deletions
@@ -0,0 +1,172 @@
/**
* 这一份钉的是 Task 9 评审逮出的三条 Important 与一条设计律 ——
* 它们都在拉取 / 发信 / 分发那几条**当时还没有测试**的路上。
*/
import { describe, expect, it } from 'vitest';
import { mailSendInputSchema } from '@contracts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { buildInboundRecord } from '../internal/domain/inbound-record.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import type { OutboundRecord, ParsedMail } from '../port.ts';
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const inbound = memoryInbound();
const outbound = memoryOutbound();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound,
outbound,
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('out'),
notify: { changed: (r) => void changes.push(r) },
});
return { service, accounts, inbound, outbound, imap, wecom, clock, changes };
}
const sendInput = mailSendInputSchema.parse({ to: ['buyer@acme.com'], subject: '报价', text: '见附', requestedBy: { kind: 'ops' } });
function outboundRow(over: Partial<OutboundRecord> = {}): OutboundRecord {
return {
id: 'out-9',
accountId: 'acc-1',
messageId: '<out-9@we.com>',
idempotencyKey: 'k-1',
to: ['buyer@acme.com'],
cc: [],
bcc: [],
subject: '报价',
text: '见附',
html: null,
inReplyTo: null,
references: [],
attachments: [],
state: 'sending',
error: null,
providerRef: null,
requestedBy: { kind: 'ops' },
createdAt: new Date('2026-08-25T10:00:00Z'),
sentAt: null,
...over,
};
}
describe('凭证解不开', () => {
it('密文解得开但不是 JSON:错误话里没有明文,只说「未配置凭证」', async () => {
const { service, accounts, imap } = harness();
// 解得开(plainSecrets 会回出 `pw-1234`),但那不是 JSON —— JSON.parse 的 SyntaxError 把原文嵌在 message 里
await accounts.save(account({ secretCipher: 'sealed:pw-1234' }));
const outcome = await service.pollNow('acc-1');
expect(outcome.error).not.toContain('pw-1234');
expect(outcome.error).toContain('未配置凭证');
expect(accounts.rows.get('acc-1')?.lastPollError).not.toContain('pw-1234');
expect(accounts.rows.get('acc-1')?.lastPollError).toContain('未配置凭证');
// 凭证都没打开,一次都不该去够厂商
expect(imap.pullCalls).toEqual([]);
});
});
describe('幂等键', () => {
it('同键的上一封还在飞:不发第二封,如实回没发出去', async () => {
const { service, accounts, outbound, imap, clock } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ createdAt: clock.now() }));
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(result.state).toBe('failed');
expect(result.error).toContain('仍在发送中');
expect(imap.sent).toHaveLength(0);
expect(outbound.rows.size).toBe(1);
expect(outbound.rows.get('out-9')?.state).toBe('sending');
});
it('同键的 sending 停太久:当上次崩在半路,复用那一行重发', async () => {
const { service, accounts, outbound, imap, clock } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) }));
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(result.state).toBe('sent');
expect(imap.sent).toHaveLength(1);
expect(outbound.rows.get('out-9')?.state).toBe('sent');
// 复用,不是新开一行
expect(outbound.rows.size).toBe(1);
expect(result.outboundId).toBe('out-9');
});
it('失败重发成功之后,那一行不留上一次的错误话', async () => {
const { service, accounts, outbound, imap } = harness();
await accounts.save(account());
imap.failSendWith(new Error('暂时不行'));
const first = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(first.state).toBe('failed');
expect(outbound.rows.get(first.outboundId)?.error).toContain('暂时不行');
imap.failSendWith(undefined);
const second = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(second.state).toBe('sent');
expect(outbound.rows.get(first.outboundId)?.error).toBeNull();
expect((await service.listOutbound({}))[0]?.error).toBeNull();
// 第三次同键走 resultOf(prior):既说成功又带错误的结果会让调用方按 error 分支判反
const third = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(third.state).toBe('sent');
expect(third.error).toBeUndefined();
expect(imap.sent).toHaveLength(1);
});
});
describe('卡死的 dispatching', () => {
const parsed: ParsedMail = {
messageId: '<m-1@acme.com>',
references: [],
from: { address: 'buyer@acme.com', name: '买家' },
to: [{ address: 'sales@we.com', name: '销售' }],
cc: [],
subject: '询价',
text: '请报价',
attachments: [],
};
it('自动路径不进(墙),人按的重新投递能接管(路)', async () => {
const { service, accounts, inbound, clock } = harness();
await accounts.save(account());
await inbound.insert(
buildInboundRecord({
id: 'in-1',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid-1',
parsed,
eml: new Uint8Array(),
receivedAt: clock.now(),
attachments: [],
now: clock.now(),
}),
);
let calls = 0;
service.subscribe('channel', async () => {
calls += 1;
});
// 进程崩在写 dispatching 与写结果之间:这条留痕永远停在 dispatching
const stuck = { mailId: 'in-1', subscriber: 'channel', state: 'dispatching' as const, error: null, finishedAt: null };
await inbound.setDelivery({ ...stuck, startedAt: new Date(clock.now().getTime() - 11 * 60_000) });
const reclaimed = await service.redeliver('in-1');
expect(reclaimed[0]?.state).toBe('done');
expect(calls).toBe(1);
// 新鲜的那条仍是墙:人按也不抢,免得两条线同时改一件事
await inbound.setDelivery({ ...stuck, startedAt: clock.now() });
const skipped = await service.redeliver('in-1');
expect(skipped[0]?.state).toBe('dispatching');
expect(calls).toBe(1);
});
});