按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
133 lines
5.7 KiB
TypeScript
133 lines
5.7 KiB
TypeScript
/**
|
||
* 账号选择器的行为测试。
|
||
*
|
||
* 判据集中在三件会**静默出错**的事上:
|
||
* 1. 只有一个账号时不该出现「全部邮箱」(它是噪声,会让人以为漏了谁)
|
||
* 2. 从"全部邮箱"切到某个账号后**必须重取收件箱** —— 不重取就会继续显示
|
||
* 聚合结果,看起来像切换没生效
|
||
* 3. 没有账号时不该渲染这个控件(那时界面该由登录页负责)
|
||
*/
|
||
|
||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||
import { render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
|
||
import AccountSwitcher from '../../src/components/AccountSwitcher';
|
||
import { useAccountStore } from '../../src/stores/accountStore';
|
||
import { useMailStore } from '../../src/stores/mailStore';
|
||
import { useUIStore } from '../../src/stores/uiStore';
|
||
import * as api from '../../src/api/client';
|
||
|
||
const acct = (id: string, name: string) => ({
|
||
id,
|
||
displayName: name,
|
||
gateway: 'http://192.168.2.60:8180',
|
||
token: `tok-${id}`,
|
||
username: name
|
||
});
|
||
|
||
function seed(accounts: ReturnType<typeof acct>[], activeId: string) {
|
||
useAccountStore.setState({ accounts, activeId, loaded: true } as never);
|
||
}
|
||
|
||
describe('AccountSwitcher', () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
vi.spyOn(api, 'getInbox').mockResolvedValue({ mails: [], total: 0 } as never);
|
||
useAccountStore.setState({ accounts: [], activeId: 'all', loaded: true } as never);
|
||
useMailStore.setState({ inbox: [], accountErrors: [] });
|
||
useUIStore.setState({ viewMode: 'inbox' });
|
||
});
|
||
|
||
it('没有账号时不渲染(那时该由登录页负责)', () => {
|
||
const { container } = render(<AccountSwitcher />);
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
|
||
it('★ 只有一个账号时退化成静态标题:不出现「全部邮箱」', async () => {
|
||
seed([acct('a1', '工作邮箱')], 'a1');
|
||
render(<AccountSwitcher />);
|
||
const btn = screen.getByTestId('account-switcher');
|
||
expect(btn).toHaveTextContent('工作邮箱');
|
||
|
||
await userEvent.click(btn);
|
||
// 点不开:聚合没有意义(一个账号的"全部"就是它自己)
|
||
expect(screen.queryByTestId('account-menu')).toBeNull();
|
||
expect(screen.queryByTestId('account-option-all')).toBeNull();
|
||
});
|
||
|
||
it('两个账号时可以展开,菜单里有「全部邮箱」与各账号', async () => {
|
||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||
render(<AccountSwitcher />);
|
||
|
||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||
expect(screen.getByTestId('account-menu')).toBeInTheDocument();
|
||
expect(screen.getByTestId('account-option-all')).toHaveTextContent('全部邮箱');
|
||
expect(screen.getByTestId('account-option-a1')).toHaveTextContent('工作邮箱');
|
||
expect(screen.getByTestId('account-option-a2')).toHaveTextContent('私人邮箱');
|
||
});
|
||
|
||
it('★ 切到某个账号后必须重取收件箱(否则会继续显示聚合结果)', async () => {
|
||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'all');
|
||
render(<AccountSwitcher />);
|
||
|
||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||
await userEvent.click(screen.getByTestId('account-option-a2'));
|
||
|
||
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('a2'));
|
||
expect(api.getInbox).toHaveBeenCalled();
|
||
});
|
||
|
||
it('★ 切到「全部邮箱」也要重取(聚合要并发问多个账号)', async () => {
|
||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||
render(<AccountSwitcher />);
|
||
|
||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||
await userEvent.click(screen.getByTestId('account-option-all'));
|
||
|
||
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('all'));
|
||
// 两个可用账号 → 走聚合路径(getInboxWithAuth 每个账号一次)
|
||
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
|
||
await useMailStore.getState().fetchInbox('all');
|
||
expect(withAuth).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('聚合模式下**只有一个可用账号**时不并发两个请求(第二条是多余失败面)', async () => {
|
||
useAccountStore.setState({
|
||
accounts: [{ ...acct('a1', '工作邮箱') }, { ...acct('a2', '坏账号'), token: '' }],
|
||
activeId: 'all',
|
||
loaded: true
|
||
} as never);
|
||
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
|
||
await useMailStore.getState().fetchInbox('all');
|
||
expect(withAuth).not.toHaveBeenCalled();
|
||
expect(api.getInbox).toHaveBeenCalled();
|
||
});
|
||
|
||
it('★ 某个账号取失败必须在 store 里说出来,而不是静默少一份', async () => {
|
||
useAccountStore.setState({
|
||
accounts: [acct('a1', '工作邮箱'), acct('a2', '私人邮箱')],
|
||
activeId: 'all',
|
||
loaded: true
|
||
} as never);
|
||
vi.spyOn(api, 'getInboxWithAuth')
|
||
.mockResolvedValueOnce({ mails: [{ mail_id: 'm1' }], total: 1 } as never)
|
||
.mockRejectedValueOnce(new Error('HTTP 401:密钥无效'));
|
||
|
||
await useMailStore.getState().fetchInbox('all');
|
||
|
||
expect(useMailStore.getState().inbox.map((m: any) => m.mail_id)).toEqual(['m1']);
|
||
expect(useMailStore.getState().accountErrors).toEqual([
|
||
{ account: '私人邮箱', error: 'HTTP 401:密钥无效' }
|
||
]);
|
||
});
|
||
|
||
it('「管理账号…」跳到账号页', async () => {
|
||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||
render(<AccountSwitcher />);
|
||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||
await userEvent.click(screen.getByTestId('account-manage'));
|
||
expect(useUIStore.getState().viewMode).toBe('account');
|
||
});
|
||
});
|