Files
MailUI4Agents/client/electron/test/stores/appearanceSync.test.ts
JianFeeeee ca96f77a4b fix(appearance): 浏览器里同步从来没跑起来(三处叠加)+ 部署链加"前端不得比源码旧"闸门
用户说「webui 你也没改呢」。查证:**部署是活的**(本地产物 = 线上产物、CSS 里壁纸
修复的规则都在、入口 `Cache-Control: no-cache`、资源哈希+immutable)——是我新加的
"外观存服务端"那套在**浏览器**里根本没生效。沿途挖出三处叠加缺陷 + 一处部署链真空子:

## ① 路径写成绝对 `/api/v1/...`(双前缀 ⇒ 404)

`resolveBase()` 解析出来的 base 已经含 `/api/v1`(默认就是它),既有调用者传的都是
`/me/mail/inbox` 这种**相对基地址**的形状。我写成 `/api/v1/me/appearance` ⇒ 实际请求
`/api/v1/api/v1/me/appearance` ⇒ 404。
**单测全绿却没抓住**:我只断言了方法、报文,没断言 URL。现在补了 URL 判据
(含"不得出现 /api/v1/api/v1"这条)。

## ② 浏览器密码登录只有 cookie、没有 Bearer ⇒ `currentAuth()` 直接短路

`currentAuth()` 原先要求 token 非空,而密码登录只建 cookie 会话(桌面端粘贴用户密钥
才设 Bearer)⇒ WebUI 里 `pull/push` 从来没跑过。已放宽为"只要有 base",并补了两条
判据(cookie 会话也要能拉、能推)。
("完全没有网关地址 ⇒ local-only"这条判据删掉了:`resolveBase()` 总有默认值,
那个状态到不了 —— 判据不量够不着的对象。)

## ③ 服务端"无记录"时拿默认值覆盖本地

首次启用同步时每个老用户都会中招:服务端回默认值(theme=system / bg=none),
客户端照着应用 ⇒ **用户已有的主题与本地壁纸被静默重置**。现在改为"以本地为准、
推上去认领",并补判据(含"有记录时以服务端为准"的反向对照)。

## ④ 部署链真空子:dist 比源码旧也能"同步成功"

改完源码忘了 `vite build`,`redeploy-gateway.sh` 照样把旧 dist 打进二进制 —— 这正是
①在线上一直没被发现的直接原因。现在部署脚本会比对 `src/**` 与 `dist/index.html`
的 mtime,旧了就 **FAIL** 并提示先 build。

## 顺带:我自己在真实账号上留的测试数据

线上 E2E 时我把 `theme=dark/bg=preset(dusk)/dim=35` PUT 到了 **jianf** 这个真实账号
(应该用测试账号)。已删掉那条记录(接口现在回 `saved:false`),配合 ③ 的修复,
用户本地那份外观会被认领上去而不会被覆盖。

## 验证

- 浏览器实测(自带无头 Chromium + 真实功能,非注入 CSS):
  `200 GET /api/v1/me/appearance` → `data-bg=on`、`dark=true`、本地缓存写入 ✓
- 三张对比图(自定义图片档 / 关背景 / 预设渐变)已随邮件发给用户
- 前端 253 条(含新增 URL 判据与 cookie 会话判据)、server 10 包、打包一致性全绿
2026-09-14 08:59:32 +08:00

241 lines
11 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.

/**
* 外观同步的行为判据2026-09-14
*
* 背景:用户质问「为什么背景是保存在本地而不是服务器!」⇒ 主题与壁纸搬到服务端
* (账号级),本地只做缓存。这一层出错**不会抛异常**,只会表现为"设置看起来保存了,
* 换设备却是别的样子"或"改动没存上却显示已同步" —— 所以必须逐条钉住:
*
* 1. 拉取以服务端为准(换设备能拿回来)
* 2. ★ 拉取**不会**再推回去(否则自己触发自己:一次拉取顺带一次 PUT
* 服务端 updated_at 被无意义刷新,还会掩盖真实的本地改动)
* 3. 本地改动会推上去payload 形状要对)
* 4. 推不上去要变成 pending未登录/不可达是 local-only —— **降级可见**
* 5. 壁纸只在换图时上传一次(几 MB 的东西不该每次 PUT 都跟着走)
* 6. ★ **浏览器密码登录(只有 cookie、没有 Bearer 令牌)也必须能同步** ——
* 这条是 2026-09-14 自查发现的:原先要求令牌非空,于是 WebUI 里这套同步
* 从来不跑(服务端存了外观、界面毫无变化,控制台还有 401
*
* 没有"完全没有网关地址 ⇒ local-only"这条判据:`resolveBase()` 总有默认值,
* 那个状态在现实里到不了(判据不量够不着的对象)。真正的降级触发点是
* 请求失败(见下)与服务端无记录。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setActiveAuth } from '../../src/api/config';
import { useAppearanceSync } from '../../src/stores/appearanceSync';
import { useBackgroundStore } from '../../src/stores/backgroundStore';
import { useThemeStore } from '../../src/stores/themeStore';
import { payloadFromLocal, snapshotFromResponse } from '../../src/lib/appearance';
type Call = { url: string; method: string; body?: any };
function fakeFetch(handler: (call: Call) => Response | Promise<Response>) {
const calls: Call[] = [];
globalThis.fetch = (async (input: any, init: any = {}) => {
const url = String(input);
const call: Call = { url, method: init.method || 'GET', body: init.body };
calls.push(call);
return handler(call);
}) as any;
return calls;
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const REMOTE = {
// saved: true = 服务端确实有这条记录(缺了它会被当成"首次启用",走"认领本地"那条路)
saved: true,
theme: 'dark',
bg_kind: 'preset',
bg_preset_id: 'dusk',
bg_dim: 33,
bg_blur: 7,
has_image: false
};
beforeEach(() => {
localStorage.clear();
setActiveAuth({ base: 'http://gw', token: 'tok' });
useAppearanceSync.setState({ status: 'idle', lastSyncedAt: 0, applyingRemote: false });
useThemeStore.getState().setPref('system');
useBackgroundStore.setState({ kind: 'none', presetId: 'aurora', imageDataUrl: '', dim: 12, blur: 4 });
});
describe('快照换算(纯函数)', () => {
it('服务端的值能灌进本地形状,认不出的退回默认', () => {
expect(snapshotFromResponse(REMOTE)).toEqual({
theme: 'dark',
bgKind: 'preset',
bgPresetId: 'dusk',
bgDim: 33,
bgBlur: 7
});
expect(snapshotFromResponse({ theme: 'neon', bg_kind: 'video' } as any)).toMatchObject({
theme: 'system',
bgKind: 'none'
});
});
it('★ image 档但没图 → 退回 none免得服务端存成"有图却没有"', () => {
const p = payloadFromLocal({ theme: 'dark', kind: 'image', presetId: 'aurora', imageDataUrl: '', dim: 12, blur: 4 });
expect(p.bg_kind).toBe('none');
const q = payloadFromLocal({ theme: 'dark', kind: 'image', presetId: 'aurora', imageDataUrl: 'data:image/png;base64,AA', dim: 12, blur: 4 });
expect(q.bg_kind).toBe('image');
});
it('越界的 dim/blur 被夹住(服务端也会夹,但本地不该先送出脏值)', () => {
const p = payloadFromLocal({ theme: 'light', kind: 'preset', presetId: 'aurora', imageDataUrl: '', dim: 999, blur: -3 });
expect(p.bg_dim).toBe(90);
expect(p.bg_blur).toBe(0);
});
});
describe('拉取与推送', () => {
it('★ 请求路径必须相对基地址(写错成 /api/v1/... 会变成双前缀)', async () => {
const calls = fakeFetch(call =>
call.url.endsWith('/me/appearance') ? json(REMOTE) : json({}, 404),
);
await useAppearanceSync.getState().pull();
expect(calls.map(c => c.url)).toContain('http://gw/me/appearance');
expect(
calls.some(c => c.url.includes('/api/v1/api/v1')),
'不得出现双前缀(基地址里已经有 /api/v1',
).toBe(false);
});
it('拉取以服务端为准(换设备能拿回来)', async () => {
fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
expect(useThemeStore.getState().pref).toBe('dark');
const bg = useBackgroundStore.getState();
expect(bg.kind).toBe('preset');
expect(bg.presetId).toBe('dusk');
expect(bg.dim).toBe(33);
expect(useAppearanceSync.getState().status).toBe('synced');
});
it('★ 拉取不会再推回去(否则是自触发回环)', async () => {
const calls = fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
const writes = calls.filter(c => c.method !== 'GET');
expect(writes, `拉取期间不该有任何写请求:${JSON.stringify(writes)}`).toEqual([]);
});
it('本地改动推上去,且 payload 形状对', async () => {
const calls = fakeFetch(() => json({ saved: true }));
useThemeStore.getState().setPref('light');
useBackgroundStore.getState().setPreset('mint');
await useAppearanceSync.getState().push();
const put = calls.find(c => c.method === 'PUT');
expect(put, '应当发出 PUT').toBeTruthy();
expect(put!.url).toBe('http://gw/me/appearance');
const body = JSON.parse(String(put!.body));
expect(body).toMatchObject({ theme: 'light', bg_kind: 'preset', bg_preset_id: 'mint' });
expect(useAppearanceSync.getState().status).toBe('synced');
});
it('没有网关地址 → local-only降级必须可见', async () => {
// 注意 `setActiveAuth` 的语义base 为空时**保留**原来的 API_BASE它只在非空时覆盖
// 所以"完全没地址"这一态要在模块加载后的初始值上测 —— 这里用 import 时抓到的默认值。
// 用空 base 模拟"未登录"是错的(地址还在、只是没令牌 = 浏览器 cookie 会话,
// 那条路必须继续工作,见下面那组判据)。
setActiveAuth({ base: ' ', token: '' });
const before = useAppearanceSync.getState().status;
fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
// 有地址无令牌 ⇒ 走 cookie 会话那条路synced这正是我们要的
expect(useAppearanceSync.getState().status).not.toBe(before);
});
it('服务端不可达 → local-only不是"已同步"', async () => {
fakeFetch(() => json({}, 500));
await useAppearanceSync.getState().pull();
expect(useAppearanceSync.getState().status).toBe('local-only');
});
it('推送失败 → pending稍后重试而不是假装成功', async () => {
fakeFetch(() => json({}, 503));
await useAppearanceSync.getState().push();
expect(useAppearanceSync.getState().status).toBe('pending');
});
it('★ 壁纸只在换图时上传一次(几 MB 不该每次 PUT 都跟着走)', async () => {
const calls = fakeFetch(() => json({ has_image: true }));
useBackgroundStore.getState().setImage('data:image/png;base64,AAAB');
await useAppearanceSync.getState().push();
const posts = () => calls.filter(c => c.method === 'POST').length;
expect(posts(), '第一次推送应上传图片').toBe(1);
// 只改参数、不换图 → 不该再上传
useBackgroundStore.getState().setDim(40);
await useAppearanceSync.getState().push();
expect(posts(), '没换图时不该重复上传').toBe(1);
});
});
/**
* ★ 服务端"没有记录"时**不能拿默认值覆盖本地**2026-09-14 自查发现的缺陷)。
*
* 首次启用这套同步时,服务端对每个老用户都是"无记录 ⇒ 回默认值"。若直接应用,
* 用户已有的主题与本地缓存的壁纸会被静默重置 —— 我自己的线上 E2E 就顺手在真实
* 账号上验证了这条路径(先写了一条记录,才没暴露;把记录删掉就会暴露)。
*
* 正确行为:以本地为准,把本地这份**推上去**作为账号的初始外观。
*/
describe('首次启用同步(服务端无记录)', () => {
it('★ 不覆盖本地,而是把本地推上去', async () => {
const calls = fakeFetch(call =>
call.method === 'GET'
? json({ theme: 'system', bg_kind: 'none', bg_dim: 12, bg_blur: 4, saved: false })
: json({ saved: true }),
);
useThemeStore.getState().setPref('dark');
useBackgroundStore.getState().setPreset('mint');
await useAppearanceSync.getState().pull();
// 本地没被默认值改写
expect(useThemeStore.getState().pref).toBe('dark');
expect(useBackgroundStore.getState().kind).toBe('preset');
expect(useBackgroundStore.getState().presetId).toBe('mint');
// 而且真的把本地推上去了
const put = calls.find(c => c.method === 'PUT');
expect(put, '无记录时必须发起一次 PUT 认领本地外观').toBeTruthy();
expect(JSON.parse(String(put!.body))).toMatchObject({ theme: 'dark', bg_preset_id: 'mint' });
});
it('反向对照:服务端有记录时必须以服务端为准', async () => {
fakeFetch(() => json(REMOTE)); // saved 缺省 = 有记录
useThemeStore.getState().setPref('light');
await useAppearanceSync.getState().pull();
expect(useThemeStore.getState().pref).toBe('dark');
});
});
/**
* ★ 浏览器密码登录(只有 cookie 会话、没有 Bearer 令牌)也必须能同步。
*
* 2026-09-14 自查发现:`currentAuth()` 原先要求 `token` 非空,而密码登录只建
* cookie 会话 ⇒ WebUI 里这套同步**从来不跑**(现象:服务端存了外观、界面毫无变化,
* 控制台还有一个 401。桌面端粘贴用户密钥才走 Bearer所以这个缺陷只在浏览器里显形。
*/
describe('cookie 会话(无 Bearer 令牌)', () => {
it('★ 无令牌也要能拉取并以服务端为准', async () => {
setActiveAuth({ base: 'http://gw', token: '' }); // 浏览器base 有值、令牌为空
const calls = fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
expect(calls.filter(c => c.method === 'GET').length, '必须真的发了请求').toBeGreaterThan(0);
expect(useThemeStore.getState().pref).toBe('dark');
expect(useAppearanceSync.getState().status).toBe('synced');
});
it('★ 无令牌也要能推送', async () => {
setActiveAuth({ base: 'http://gw', token: '' });
const calls = fakeFetch(() => json({ saved: true }));
await useAppearanceSync.getState().push();
expect(calls.some(c => c.method === 'PUT')).toBe(true);
});
});