Archived
mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)
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,61 @@
|
||||
/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mailAccountInputSchema } from '@contracts';
|
||||
import type { MailAccountRecord } from '../internal/domain/records.ts';
|
||||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||||
import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts';
|
||||
|
||||
const now = new Date('2026-08-25T10:00:00Z');
|
||||
const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } });
|
||||
|
||||
describe('mergeAccount / secretOf', () => {
|
||||
it('新建:id / 时间 / 密文来自 base,游标空', () => {
|
||||
const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now });
|
||||
expect(record.id).toBe('acc-1');
|
||||
expect(record.address).toBe('sales@we.com');
|
||||
expect(record.secretCipher).toBe('sealed');
|
||||
expect(record.cursor).toBeNull();
|
||||
expect(record.createdAt).toBe(now);
|
||||
expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' }));
|
||||
expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined();
|
||||
});
|
||||
it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => {
|
||||
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
|
||||
expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe('');
|
||||
const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now });
|
||||
const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' };
|
||||
expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work');
|
||||
});
|
||||
it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => {
|
||||
const existing: MailAccountRecord = {
|
||||
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
|
||||
cursor: imapCursor('7', 9),
|
||||
lastPollError: '旧错误',
|
||||
};
|
||||
expect(cursorReset(existing, input)).toBe(false);
|
||||
const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now });
|
||||
expect(same.cursor).toEqual(imapCursor('7', 9));
|
||||
const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } });
|
||||
expect(cursorReset(existing, changed)).toBe(true);
|
||||
const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now });
|
||||
expect(reset.cursor).toBeNull();
|
||||
expect(reset.lastPollError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewOf', () => {
|
||||
it('不带密文只带 secretBound;status 带游标摘要与 polling', () => {
|
||||
const record: MailAccountRecord = {
|
||||
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }),
|
||||
cursor: imapCursor('7', 9),
|
||||
pollingUntil: new Date(now.getTime() + 1000),
|
||||
};
|
||||
const view = viewOf(record, now);
|
||||
expect(view.secretBound).toBe(true);
|
||||
expect(JSON.stringify(view)).not.toContain('sealed');
|
||||
expect(view.status.cursor).toBe('UID 9 · validity 7');
|
||||
expect(view.status.polling).toBe(true);
|
||||
expect(view.kind).toBe('imap-smtp');
|
||||
if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts';
|
||||
|
||||
describe('delivery', () => {
|
||||
it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => {
|
||||
expect(canRedeliver(undefined)).toBe(true);
|
||||
expect(canRedeliver('done')).toBe(true);
|
||||
expect(canRedeliver('failed')).toBe(true);
|
||||
expect(canRedeliver('skipped')).toBe(true);
|
||||
expect(canRedeliver('dispatching')).toBe(false);
|
||||
});
|
||||
it('回调的返回值定状态', () => {
|
||||
expect(stateOfOutcome(undefined)).toBe('done');
|
||||
expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MailAccountRecord } from '../internal/domain/records.ts';
|
||||
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts';
|
||||
import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts';
|
||||
|
||||
const now = new Date('2026-08-25T10:00:00Z');
|
||||
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
|
||||
return {
|
||||
id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
|
||||
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null,
|
||||
lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] };
|
||||
|
||||
describe('validateSend', () => {
|
||||
it('合法入参零问题', () => {
|
||||
expect(validateSend(base, 'imap-smtp', [])).toEqual([]);
|
||||
});
|
||||
it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => {
|
||||
expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']);
|
||||
expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB');
|
||||
expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB');
|
||||
});
|
||||
it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => {
|
||||
expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200');
|
||||
expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickAccount', () => {
|
||||
const a = account({ id: 'a', isDefault: true });
|
||||
const b = account({ id: 'b', address: 'b@we.com', enabled: false });
|
||||
it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => {
|
||||
expect(pickAccount([a, b], {}).id).toBe('a');
|
||||
expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError);
|
||||
});
|
||||
it('指定 id:不存在 404、停用 412', () => {
|
||||
expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a');
|
||||
expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError);
|
||||
expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError);
|
||||
});
|
||||
it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => {
|
||||
expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a');
|
||||
expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError);
|
||||
expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageIdFor', () => {
|
||||
it('<id@域>;地址没有域用 local', () => {
|
||||
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
|
||||
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user