Files
MailUI4Agents/client/electron/test/components/AccountSwitcher.test.tsx
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

133 lines
5.7 KiB
TypeScript
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.

/**
* 账号选择器的行为测试。
*
* 判据集中在三件会**静默出错**的事上:
* 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');
});
});