Files
MailUI4Agents/client/electron/test/lib/accounts.test.mjs
JianFeeeee addde97600 feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与
写信账号切换留作下一轮)。

- `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并),
  16 条测试钉住每条判据(含反向对照)。
- 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+
  0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与
  http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。
- 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的
  令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。
- ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的
  服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。
- ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表
  少一整份邮件而界面看起来完全正常。
- `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它
  (`client.ts` 的 `const BASE` 快照已改成每次读)。
- UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页
  「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。
- 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、
  typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00

187 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `src/lib/accounts.ts` 的测试。
*
* 这一层的判据是「多账号怎么合并、谁是默认、什么算同一个身份」——
* 它们错了都不会报错,只会表现为「同一封信出现两次」「切了账号还是老数据」
* 「重复添加了三个同样的账号」。所以每条都配了反向对照。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
AGGREGATE_ID,
normalizeGateway,
deriveDisplayName,
accountAuth,
isUsableAccount,
upsertAccount,
removeAccount,
sameIdentity,
pickDefaultAccountId,
touchAccount,
mergeInboxes
} from '../../src/lib/accounts.ts';
const acct = (over = {}) => ({
id: 'a1',
displayName: '工作邮箱',
gateway: 'http://192.168.2.60:8180',
token: 'k1',
...over
});
// ─── 地址规范化 ─────────────────────────────────────────────────────────
test('★ 网关地址的各种写法都收敛成同一个(否则会重复添加同一个账号)', () => {
const want = 'http://192.168.2.60:8180';
for (const raw of [
'http://192.168.2.60:8180',
'http://192.168.2.60:8180/',
'http://192.168.2.60:8180/api/v1',
'http://192.168.2.60:8180/api/v1/',
' http://192.168.2.60:8180 ',
'192.168.2.60:8180', // 用户常只写 ip:port
'192.168.2.60:8180/'
]) {
assert.equal(normalizeGateway(raw), want, `输入 ${JSON.stringify(raw)}`);
}
});
test('空串 / 缺协议以外的东西不炸', () => {
assert.equal(normalizeGateway(''), '');
assert.equal(normalizeGateway(' '), '');
assert.equal(normalizeGateway(undefined), '');
// https 不该被改写成 http
assert.equal(normalizeGateway('https://mail.example.com'), 'https://mail.example.com');
});
test('★ accountAuth 拼出的 base 恰好是 /api/v1不能出现双份', () => {
assert.deepEqual(accountAuth(acct()), { base: 'http://192.168.2.60:8180/api/v1', token: 'k1' });
// 粘贴了带 /api/v1 的地址也不该拼成 /api/v1/api/v1
assert.equal(
accountAuth(acct({ gateway: 'http://h:8180/api/v1' })).base,
'http://h:8180/api/v1'
);
});
test('显示名可派生:有用户名带用户名,没有就用主机名', () => {
assert.equal(deriveDisplayName('http://192.168.2.60:8180', 'gui-lab'), 'gui-lab@192.168.2.60:8180');
assert.equal(deriveDisplayName('http://192.168.2.60:8180'), '192.168.2.60:8180');
assert.equal(deriveDisplayName(''), '未命名账号');
});
test('可用性判据:有地址且有令牌才算可用', () => {
assert.equal(isUsableAccount(acct()), true);
assert.equal(isUsableAccount(acct({ token: ' ' })), false);
assert.equal(isUsableAccount(acct({ gateway: '' })), false);
assert.equal(isUsableAccount(null), false);
// 反向对照:宽松是刻意的 —— 自建网关的相对地址也不该在这里被拒
assert.equal(isUsableAccount(acct({ gateway: 'localhost:8180' })), true);
});
// ─── 增删与身份判重 ─────────────────────────────────────────────────────
test('★ 同一个身份不能重复添加id 不同但网关+令牌相同)', () => {
const a = acct({ id: 'a1' });
const b = acct({ id: 'a2' }); // 新建时 uuid 必然不同,所以不能靠 id 判重
assert.equal(sameIdentity(a, b), true);
// 网关写法不同但其实是同一台机器 → 也算同一个身份
assert.equal(sameIdentity(a, acct({ gateway: '192.168.2.60:8180/' })), true);
// 反向对照:换了令牌或换了网关就不是同一个身份
assert.equal(sameIdentity(a, acct({ token: 'k2' })), false);
assert.equal(sameIdentity(a, acct({ gateway: 'http://other:8180' })), false);
});
test('upsert 同 id 覆盖、不产生两条', () => {
const list = [acct({ id: 'a1', displayName: '旧名' })];
const out = upsertAccount(list, acct({ id: 'a1', displayName: '新名' }));
assert.equal(out.length, 1);
assert.equal(out[0].displayName, '新名');
const out2 = upsertAccount(out, acct({ id: 'b1', displayName: '另一个' }));
assert.equal(out2.length, 2);
});
test('删除幂等:删不存在的 id 返回原样', () => {
const list = [acct({ id: 'a1' }), acct({ id: 'a2' })];
assert.equal(removeAccount(list, 'a1').length, 1);
assert.equal(removeAccount(list, 'nope').length, 2);
});
// ─── 默认账号 ───────────────────────────────────────────────────────────
test('★ 默认账号 = 最近用过的那个;都没用过则取第一个', () => {
const list = [
acct({ id: 'a1' }),
acct({ id: 'a2', lastUsed: '2026-09-07T12:00:00Z' }),
acct({ id: 'a3', lastUsed: '2026-09-08T12:00:00Z' })
];
assert.equal(pickDefaultAccountId(list), 'a3');
// 反向对照:一个都没有 lastUsed 时取列表第一个(顺序稳定由调用方保证)
assert.equal(pickDefaultAccountId([acct({ id: 'a1' }), acct({ id: 'a2' })]), 'a1');
// 空列表返回空串,不是抛错
assert.equal(pickDefaultAccountId([]), '');
});
test('touch 只改那一个账号的时间,不动顺序与其它字段', () => {
const list = [acct({ id: 'a1' }), acct({ id: 'a2' })];
const out = touchAccount(list, 'a2', '2026-09-12T00:00:00Z');
assert.equal(out[1].lastUsed, '2026-09-09T00:00:00Z'.replace('2026-09-09', '2026-09-12'));
assert.equal(out[0].lastUsed, undefined);
assert.deepEqual(out.map(a => a.id), ['a1', 'a2']);
});
// ─── 聚合合并 ───────────────────────────────────────────────────────────
test('★ 合并按时间倒序(不是把几个收件箱拼起来)', () => {
const merged = mergeInboxes([
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1', created_at: '2026-09-01T00:00:00Z' }] },
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2', created_at: '2026-09-05T00:00:00Z' }] }
]);
assert.deepEqual(merged.map(m => m.mail_id), ['m2', 'm1']);
});
test('★ 同一 mail_id 只留一条,且归属是第一条胜出', () => {
const merged = mergeInboxes([
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'same', created_at: '2026-09-01T00:00:00Z' }] },
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'same', created_at: '2026-09-02T00:00:00Z' }] }
]);
assert.equal(merged.length, 1, '同一封信出现两次 ⇒ 删除/已读只在一侧生效,看起来"点过了还在"');
assert.equal(merged[0].account_id, 'a1');
assert.equal(merged[0].account_name, '甲');
});
test('★ 缺 created_at 的排在最后(不能因为缺字段就排到最前)', () => {
const merged = mergeInboxes([
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'no-time' }] },
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'has-time', created_at: '2020-01-01T00:00:00Z' }] }
]);
assert.deepEqual(merged.map(m => m.mail_id), ['has-time', 'no-time']);
});
test('每条都带归属(界面才有徽标可画)', () => {
const merged = mergeInboxes([
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1' }] },
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2' }] }
]);
assert.deepEqual(
merged.map(m => m.account_name).sort(),
['乙', '甲']
);
});
test('空账号 / 空列表都不炸', () => {
assert.deepEqual(mergeInboxes([]), []);
assert.deepEqual(mergeInboxes([{ account: { id: 'a1', displayName: '甲' }, mails: [] }]), []);
// 没有 mail_id 的条目不能把其它条目挤掉(它们各自都留下来)
const merged = mergeInboxes([
{ account: { id: 'a1', displayName: '甲' }, mails: [{ created_at: '2026-01-01T00:00:00Z' }, {}] }
]);
assert.equal(merged.length, 2);
});
test('聚合 id 与真实账号 id 不会冲突(真实 id 是 uuid', () => {
assert.equal(AGGREGATE_ID, 'all');
assert.equal(/^[0-9a-f-]{36}$/.test(AGGREGATE_ID), false, '聚合 id 不能长得像 uuid');
});