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