Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts
T
kdletters 97b0231afc
Project CI / Frontend tests (push) Successful in 4m33s
Project CI / Repository checks (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 8m38s
Project CI / Native shell tests (push) Successful in 18m37s
优化 AGC 会话恢复超时与 Runner 启动校验 (#253)
## 变更内容

- 增加启动会话恢复阶段、等待时间和可操作错误提示。
- 为客户端 HTTP 请求增加默认超时、AbortController 取消和可选长请求豁免。
- 会话恢复失败后提供重试,并隔离旧请求的迟到结果。
- 让 Runner 启动探测严格遵守剩余启动时间预算。
- 补充前端、HTTP、Runner 定向测试并同步技术方案。

## 验证

- npm run typecheck --workspace @genarrative/ai-game-creator-shell
- npm run check:encoding
- clientHttp 定向测试
- AuthenticatedClient 定向测试
- Runner 启动探测测试
- cargo fmt --check
- git diff --check

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/253
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-02 18:15:38 +08:00

1223 lines
41 KiB
TypeScript

import { afterEach } from 'vitest';
import {
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
resetClientServerSelectionForTests,
setClientServerSelection,
} from '../../src/services/clientHttp';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
clearCommittedPlatformSession,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
resetPlatformSessionStateForTests,
} from '../../src/services/platformSession';
import {
act,
AuthenticatedClient,
expect,
fireEvent,
it,
React,
render,
screen,
testAuthUser,
vi,
waitFor,
} from './harness';
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionStateForTests();
resetClientServerSelectionForTests();
delete window.__TAURI__;
});
it('leaves startup loading with an actionable retry after auth service timeout', async () => {
vi.useFakeTimers();
let releaseRefresh: (() => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Promise<Response>((resolve) => {
releaseRefresh = () => resolve(new Response('', { status: 401 }));
});
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(screen.getByRole('main', { name: '登录状态检查' })).not.toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(screen.getByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByRole('alert')).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).not.toBeNull();
releaseRefresh?.();
await Promise.resolve();
vi.useRealTimers();
});
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
const login = await screen.findByRole('main', { name: '登录' });
expect(login.className).toContain('platform-theme');
expect(login.className).toContain('platform-theme--light');
expect(screen.getByRole('img', { name: '陶泥儿' })).not.toBeNull();
expect(
screen.getByRole('heading', { name: '登录陶泥儿 GameAgent' }),
).not.toBeNull();
});
it('keeps the client at login when the native platform session cannot be installed', async () => {
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string) => {
if (command === 'install_platform_account_session') {
throw new Error('runner unavailable');
}
return null;
}),
},
};
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
return new Response(
JSON.stringify({
token: 'phone-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.id),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('runner unavailable')).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe(null);
});
it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
let resolveLogin: ((response: Response) => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
return await new Promise<Response>((resolve) => {
resolveLogin = resolve;
});
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.id),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull());
expect(
(screen.getByLabelText('服务器') as HTMLSelectElement).disabled,
).toBe(true);
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
resolveLogin?.(
new Response(
JSON.stringify({
token: 'origin-a-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
),
);
expect(await screen.findByRole('main', { name: '已登录' })).not.toBeNull();
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'origin-a-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
expect(invoke).not.toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({ apiBaseUrl: AGC_RELEASE_API_BASE_URL }),
);
});
it('reserves install and clear generations above the native floor after renderer state resets', async () => {
let nativeGenerationFloor = 57;
const mutations: Array<{
command: string;
generation: number;
}> = [];
const invoke = vi.fn(async (command: string, payload?: unknown) => {
if (command === 'read_platform_account_session_generation') {
return nativeGenerationFloor;
}
if (
command === 'install_platform_account_session' ||
command === 'clear_platform_account_session'
) {
const generation = (payload as { generation?: number } | undefined)
?.generation;
if (generation === undefined) {
throw new Error('missing native session generation');
}
mutations.push({ command, generation });
nativeGenerationFloor = generation;
}
return null;
});
window.__TAURI__ = { core: { invoke } };
resetPlatformSessionStateForTests();
const installFloor = nativeGenerationFloor;
const loginGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'renderer-reload-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration);
expect(mutations[0]).toEqual({
command: 'install_platform_account_session',
generation: expect.any(Number),
});
expect(mutations[0]?.generation).toBeGreaterThan(installFloor);
resetPlatformSessionStateForTests();
const clearFloor = nativeGenerationFloor;
const logoutGeneration = beginPlatformSessionClearTransition();
await clearCommittedPlatformSession(logoutGeneration);
expect(mutations[1]).toEqual({
command: 'clear_platform_account_session',
generation: expect.any(Number),
});
expect(mutations[1]?.generation).toBeGreaterThan(clearFloor);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_platform_account_session_generation',
),
).toHaveLength(2);
});
it('keeps the previous renderer session authoritative when replacement install is rejected', async () => {
const invoke = vi.fn(async (_command: string, payload?: unknown) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (userId === 'user-b') {
throw new Error('replacement rejected');
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await expect(
commitAuthenticatedPlatformSession(accountB, accountBGeneration),
).rejects.toThrow('replacement rejected');
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-a-token');
});
it('reconciles a stale native install back to the last committed renderer authority', async () => {
let resolveAccountBInstall: (() => void) | null = null;
const invoke = vi.fn(async (command: string, payload?: unknown) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (
command === 'install_platform_account_session' &&
userId === 'user-b'
) {
await new Promise<void>((resolve) => {
resolveAccountBInstall = resolve;
});
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
);
await waitFor(() => expect(resolveAccountBInstall).not.toBeNull());
beginPlatformSessionTransition();
resolveAccountBInstall?.();
await expect(accountBCommit).resolves.toBeNull();
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: testAuthUser.id,
accessToken: 'account-a-token',
}),
);
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-a-token');
});
it('keeps a queued logout authoritative after an older native install completes', async () => {
let resolveAccountBInstall: (() => void) | null = null;
const invoke = vi.fn(async (command: string, payload?: unknown) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (
command === 'install_platform_account_session' &&
userId === 'user-b'
) {
await new Promise<void>((resolve) => {
resolveAccountBInstall = resolve;
});
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
);
await waitFor(() => expect(resolveAccountBInstall).not.toBeNull());
const logoutGeneration = beginPlatformSessionClearTransition();
const clear = clearCommittedPlatformSession(logoutGeneration);
resolveAccountBInstall?.();
await expect(accountBCommit).resolves.toBeNull();
await expect(clear).resolves.toBeUndefined();
expect(invoke).toHaveBeenLastCalledWith(
'clear_platform_account_session',
expect.objectContaining({
generation: expect.any(Number),
}),
);
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe(null);
});
it('keeps a newer queued login valid after reconciling an older native install', async () => {
let resolveAccountBInstall: (() => void) | null = null;
const invoke = vi.fn(async (command: string, payload?: unknown) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (
command === 'install_platform_account_session' &&
userId === 'user-b'
) {
await new Promise<void>((resolve) => {
resolveAccountBInstall = resolve;
});
}
return null;
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
);
await waitFor(() => expect(resolveAccountBInstall).not.toBeNull());
const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' };
const accountCGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-c-token',
);
const accountCCommit = commitAuthenticatedPlatformSession(
accountC,
accountCGeneration,
);
resolveAccountBInstall?.();
await expect(accountBCommit).resolves.toBeNull();
await expect(accountCCommit).resolves.not.toBeNull();
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: 'user-c',
accessToken: 'account-c-token',
}),
);
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-c-token');
});
it('restores renderer authority when a queued login is stale before it starts', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
);
beginPlatformSessionTransition();
await expect(accountBCommit).resolves.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-a-token');
expect(
invoke.mock.calls.filter(
([command]) => command === 'install_platform_account_session',
),
).toHaveLength(1);
});
it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let resolveRefresh: ((response: Response) => void) | null = null;
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
refreshCalls += 1;
return await new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
const first = requestPlatformSessionRefresh(testAuthUser.id);
const second = requestPlatformSessionRefresh(testAuthUser.id);
expect(first).toBe(second);
expect(refreshCalls).toBe(1);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
resolveRefresh?.(
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
status: 200,
}),
);
await expect(first).resolves.toEqual({ status: 'stale' });
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-b-token');
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: 'user-b',
accessToken: 'account-b-token',
generation: currentPlatformSessionGeneration(),
}),
);
});
it('treats a late old-account refresh failure as stale after switching accounts', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let rejectRefresh: ((error: Error) => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return await new Promise<Response>((_resolve, reject) => {
rejectRefresh = reject;
});
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
rejectRefresh?.(new Error('late account A refresh failed'));
await expect(staleRefresh).resolves.toEqual({ status: 'stale' });
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-b-token');
});
it('refreshes the current account once and synchronizes the replacement token', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation);
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
refreshCalls += 1;
return new Response(JSON.stringify({ token: 'replacement-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
const result = await requestPlatformSessionRefresh(testAuthUser.id);
expect(result).toEqual(
expect.objectContaining({ status: 'refreshed', user: testAuthUser }),
);
expect(refreshCalls).toBe(1);
await waitFor(() => {
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: testAuthUser.id,
accessToken: 'replacement-token',
}),
);
});
});
it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(
testAuthUser,
generation,
AGC_DEVELOPMENT_API_BASE_URL,
);
let resolveRefresh: ((response: Response) => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return await new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
const refresh = requestPlatformSessionRefresh(testAuthUser.id);
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
resolveRefresh?.(
new Response(JSON.stringify({ token: 'replacement-token' }), {
status: 200,
}),
);
await expect(refresh).resolves.toEqual(
expect.objectContaining({ status: 'refreshed' }),
);
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'replacement-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
});
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response(JSON.stringify({ token: 'fresh-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
});
render(
React.createElement(
React.StrictMode,
null,
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
user.displayName,
),
),
),
);
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/refresh',
),
).toHaveLength(1);
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('fresh-token');
});
it('shows phone code login before entering the workspace', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByRole('button', { name: '验证码登录' })).not.toBeNull();
expect(screen.getByRole('button', { name: '密码登录' })).not.toBeNull();
expect(screen.getByLabelText('手机号')).not.toBeNull();
expect(screen.getByLabelText('验证码')).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows release, dev, and custom server choices on the login screen', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
const server = screen.getByRole('combobox', { name: '服务器' });
expect(server).not.toBeNull();
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
fireEvent.change(server, { target: { value: 'custom' } });
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'https://staging.example.com' },
});
expect(
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
).toBe('https://staging.example.com');
});
it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/send-code') {
expect(JSON.parse(String(init?.body))).toMatchObject({
countryCode: '86',
purePhoneNumber: '13800000000',
scene: 'login',
});
return new Response(
JSON.stringify({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'sms-1',
}),
{ status: 200 },
);
}
if (url === '/api/auth/phone/login') {
expect(JSON.parse(String(init?.body))).toMatchObject({
countryCode: '86',
purePhoneNumber: '13800000000',
code: '123456',
});
return new Response(
JSON.stringify({
token: 'phone-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
user.loginMethod,
),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '+86 138 0000 0000' },
});
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
expect(
await screen.findByText('验证码已发送,300 秒内有效'),
).not.toBeNull();
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(screen.getByLabelText('已登录').textContent).toBe('phone');
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('phone-token');
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/phone/send-code',
),
).toHaveLength(1);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/phone/login',
),
).toHaveLength(1);
});
it('logs in with the current password phone contract', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/entry') {
expect(JSON.parse(String(init?.body))).toEqual({
countryCode: '86',
purePhoneNumber: '13800000000',
password: 'secret123',
});
return new Response(
JSON.stringify({
token: 'password-token',
user: { ...testAuthUser, loginMethod: 'password' },
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
user.loginMethod,
),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '+86 138 0000 0000' },
});
fireEvent.change(screen.getByLabelText('密码'), {
target: { value: ' secret123 ' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('password-token');
});
it('falls back to the localized action error for non-JSON auth failures', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
return new Response(
'Failed to deserialize the JSON body into the target type',
{ status: 422, headers: { 'Content-Type': 'text/plain' } },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('登录失败')).not.toBeNull();
expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull();
});
it('shows a clear login service error instead of raw Load failed', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
throw new TypeError('Load failed');
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(
await screen.findByText(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(screen.queryByText(/Load failed/u)).toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('explains a refused login connection without exposing transport details', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
throw new TypeError('connect ECONNREFUSED 127.0.0.1:8082');
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(
await screen.findByText(
'无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口',
),
).not.toBeNull();
expect(screen.queryByText(/ECONNREFUSED|127\.0\.0\.1:8082/u)).toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('keeps the stored token when startup auth check cannot reach the service', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/me') {
throw new TypeError('Load failed');
}
throw new Error(`unexpected fetch ${url}`);
});
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
user.displayName,
),
),
);
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(
screen.getByText(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('existing-token');
expect(screen.queryByLabelText('已登录')).toBeNull();
expect(
fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'),
).toHaveLength(1);
});
it('still calls logout when token refresh fails during logout retry', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
let logoutCalls = 0;
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
if (url === '/api/auth/logout') {
logoutCalls += 1;
return new Response('', { status: logoutCalls === 1 ? 500 : 200 });
}
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${url}`);
});
render(
React.createElement(AuthenticatedClient, null, ({ user, logout }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
React.createElement('span', null, user.displayName),
React.createElement(
'button',
{ type: 'button', onClick: logout },
'退出',
),
),
),
);
expect(await screen.findByLabelText('已登录')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByText('已退出登录')).not.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe(null);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/logout',
),
).toHaveLength(2);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/refresh',
),
).toHaveLength(1);
});
it('fails the renderer closed when native session clear is rejected during logout', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string) => {
if (command === 'clear_platform_account_session') {
throw new Error('runner clear rejected');
}
return null;
}),
},
};
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
if (url === '/api/auth/logout') {
return new Response('', { status: 200 });
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user, logout }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
React.createElement('span', null, user.displayName),
React.createElement(
'button',
{ type: 'button', onClick: logout },
'退出',
),
),
),
);
expect(await screen.findByLabelText('已登录')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(
screen.getByText(
'已退出登录;本地运行时登录态同步失败,请重启客户端后再登录',
),
).not.toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe(null);
});
}