ba7c4a9bbf
- 载体用例:authNetworkFailure 与 authResponseInvalid 改为内层 reason switch,覆盖全部 reason 与 default expectNever - 网关、上报、页面用例改用 authNetworkFailure 等新变体名与类型化 reason 载荷 - 新增可枚举细分不读服务端文案的分流断言
228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* 认证命令失败的 JS 侧载体。
|
|
*
|
|
* `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体
|
|
* 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
|
|
* `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先
|
|
* `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason,
|
|
* `expectNever(...)` 都编译不过。
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { loginClientWithPassword } from '../src/services/clientAuth';
|
|
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
|
|
import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure';
|
|
import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid';
|
|
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 { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected';
|
|
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
|
|
|
|
function expectNever(value: never): void {
|
|
void value;
|
|
}
|
|
|
|
/** 编译期用例:顶层变体逐个列全;带载荷的分支先 `as`,reason 再按类型化枚举分流。 */
|
|
function variantMessage(error: ClientAuthError): string {
|
|
switch (error.type) {
|
|
case 'serverAddressRejected': {
|
|
const payload = error as ServerAddressRejected;
|
|
switch (payload.reason) {
|
|
case 'emptyOrTooLong':
|
|
return '不能为空或超长';
|
|
case 'notAUrl':
|
|
return '不是合法地址';
|
|
case 'hasCredentials':
|
|
return '不能包含凭据';
|
|
case 'hasPathOrQueryOrFragment':
|
|
return '不能带路径、查询或 fragment';
|
|
case 'notHttps':
|
|
return '远程地址不是 https';
|
|
case 'unsupportedScheme':
|
|
return '不是 http(s) 地址';
|
|
case 'outsideChannel':
|
|
return '不在当前构建渠道范围内';
|
|
default: {
|
|
expectNever(payload.reason);
|
|
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 'authNetworkFailure': {
|
|
const payload = error as AuthNetworkFailure;
|
|
switch (payload.reason) {
|
|
case 'timeout':
|
|
return '连接超时';
|
|
case 'unreachable':
|
|
return '连接失败';
|
|
default: {
|
|
expectNever(payload.reason);
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
case 'authServiceUnavailable':
|
|
return `HTTP ${(error as AuthServiceUnavailable).status}`;
|
|
case 'unexpectedRejection':
|
|
return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝';
|
|
case 'authResponseInvalid': {
|
|
const payload = error as AuthResponseInvalid;
|
|
switch (payload.reason) {
|
|
case 'notJson':
|
|
return '响应不是 JSON';
|
|
case 'invalidBody':
|
|
return '响应结构不符';
|
|
case 'missingRefreshCookie':
|
|
return '缺续期凭据';
|
|
case 'missingUserIdentity':
|
|
return '缺用户主体';
|
|
case 'serverRejected':
|
|
return payload.serverMessage ?? '服务端显式拒绝';
|
|
default: {
|
|
expectNever(payload.reason);
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
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');
|
|
});
|
|
|
|
it('可枚举细分按类型化 reason 分流,不读服务端文案', () => {
|
|
expect(
|
|
variantMessage({
|
|
type: 'serverAddressRejected',
|
|
reason: 'notHttps',
|
|
}),
|
|
).toBe('远程地址不是 https');
|
|
expect(
|
|
variantMessage({ type: 'authNetworkFailure', reason: 'timeout' }),
|
|
).toBe('连接超时');
|
|
expect(
|
|
variantMessage({
|
|
type: 'authResponseInvalid',
|
|
reason: 'serverRejected',
|
|
serverMessage: '登录服务请求失败',
|
|
}),
|
|
).toBe('登录服务请求失败');
|
|
expect(
|
|
variantMessage({
|
|
type: 'authResponseInvalid',
|
|
reason: 'missingRefreshCookie',
|
|
serverMessage: null,
|
|
}),
|
|
).toBe('缺续期凭据');
|
|
});
|
|
});
|