按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
187 lines
8.1 KiB
JavaScript
187 lines
8.1 KiB
JavaScript
/**
|
||
* `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');
|
||
});
|