Files
Genarrative/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts
T
k88936 3d6eade206 AGC认证错误用例对齐拍平变体
- 载体用例覆盖变体全集:无字段变体取固定文案,带载荷变体 as 后读 serverMessage/status/detail
- 上报判据新增 passwordLoginRejected:服务端 400 原文只展示、不写错误池
- 网关用例改用 authNetworkUnreachable / serverAddressNotHttps 等新变体名
2026-10-02 02:25:09 +08:00

174 lines
6.7 KiB
TypeScript

// @vitest-environment jsdom
/**
* 认证命令失败的 JS 侧载体。
*
* `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体
* 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
* `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先
* `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。
*/
import { describe, expect, it, vi } from 'vitest';
import { loginClientWithPassword } from '../src/services/clientAuth';
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected';
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed';
import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected';
import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected';
import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed';
import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected';
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
function expectNever(value: never): void {
void value;
}
/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */
function variantMessage(error: ClientAuthError): string {
switch (error.type) {
case 'serverAddressEmptyOrTooLong':
return '不能为空或超长';
case 'serverAddressNotAUrl':
return '不是合法地址';
case 'serverAddressHasCredentials':
return '不能包含凭据';
case 'serverAddressHasPathOrQueryOrFragment':
return '不能带路径、查询或 fragment';
case 'serverAddressNotHttps':
return '远程地址不是 https';
case 'serverAddressUnsupportedScheme':
return '不是 http(s) 地址';
case 'serverAddressOutsideChannel':
return '不在当前构建渠道范围内';
case 'phoneNumberInvalid':
return '手机号格式不正确';
case 'passwordMissing':
return '密码为空';
case 'loginCodeMissing':
return '验证码为空';
case 'passwordLoginRejected':
return (error as PasswordLoginRejected).serverMessage ?? '登录输入被拒绝';
case 'phoneOrPasswordMismatch':
return '手机号或密码错误';
case 'sendCodeRejected':
return (error as SendCodeRejected).serverMessage ?? '发码被拒绝';
case 'smsCodeThrottled':
return '发码过于频繁';
case 'phoneCodeLoginRejected':
return (
(error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝'
);
case 'smsCodeRejected':
return '验证码错误或过期';
case 'sessionInvalidated':
return '登录态失效';
case 'permissionDenied':
return '权限不足';
case 'authNetworkTimeout':
return '连接超时';
case 'authNetworkUnreachable':
return '连接失败';
case 'authServiceUnavailable':
return `HTTP ${(error as AuthServiceUnavailable).status}`;
case 'unexpectedRejection':
return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝';
case 'authResponseNotJson':
return '响应不是 JSON';
case 'authResponseInvalidBody':
return '响应结构不符';
case 'authResponseMissingRefreshCookie':
return '缺续期凭据';
case 'authResponseMissingUserIdentity':
return '缺用户主体';
case 'authResponseServerRejected':
return (
(error as AuthResponseServerRejected).serverMessage ?? '服务端显式拒绝'
);
case 'clientSessionPersistFailed':
return (error as ClientSessionPersistFailed).detail;
case 'runtimeSessionInstallFailed':
return (error as RuntimeSessionInstallFailed).detail;
case 'authClientInitFailed':
return '网络客户端构建失败';
default: {
expectNever(error);
return '';
}
}
}
function installInvoke(handler: () => Promise<unknown>) {
window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never;
}
describe('认证命令失败的 JS 侧载体', () => {
it('结构化拒绝原样装进 error 字段,不读变体字段', async () => {
// 故意不带 serverMessage:包装层绝不能假设每个变体都有这个字段。
const rejection = { type: 'phoneNumberInvalid' };
installInvoke(async () => {
throw rejection;
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ClientAuthErrorWrapper);
expect((failure as ClientAuthErrorWrapper).error).toBe(rejection);
expect((failure as Error).message).toBe('');
});
it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => {
delete window.__TAURI__;
await expect(
loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
),
).rejects.toThrow('需要在 Tauri App 内登录');
});
it('Tauri 的意外形状也按映射包装:不做嗅探,但仍是可上报的 Error', async () => {
const rejection = new Error('IPC 桥接异常');
installInvoke(async () => {
throw rejection;
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ClientAuthErrorWrapper);
expect((failure as Error).message).toBe('');
expect((failure as ClientAuthErrorWrapper).error).toBe(rejection);
});
});
describe('变体分流', () => {
it('无字段变体直接取固定文案,带载荷变体按具名类型取值', () => {
expect(variantMessage({ type: 'phoneNumberInvalid' })).toBe(
'手机号格式不正确',
);
expect(
variantMessage({
type: 'passwordLoginRejected',
serverMessage: '密码长度需要在 6 到 128 位之间',
}),
).toBe('密码长度需要在 6 到 128 位之间');
expect(
variantMessage({ type: 'passwordLoginRejected', serverMessage: null }),
).toBe('登录输入被拒绝');
expect(
variantMessage({ type: 'authServiceUnavailable', status: 503 }),
).toBe('HTTP 503');
});
});