Files
Genarrative/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts
T
k88936 e43efeb269 AGC 认证失败去掉手写载体类,直接用 ClientActionError + 生成判别联合
- 删除 clientAuthFailure.ts,invokeClientAuth 改为把拒绝装进已有 ClientActionError,cause 即 ts-rs 生成的 ClientAuthError
- 三处 catch 改为 instanceof ClientActionError 后取 error.cause 分流,每个 case 用 as 取具名载荷
- 认证载体用例改名 clientAuthErrorCarrier.test.ts,断言改为 ClientActionError.cause
- clientAuthHost 用例与 auth_session.rs 注释同步新载体
2026-10-02 01:27:18 +08:00

180 lines
7.3 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.
// @vitest-environment jsdom
/**
* 认证命令失败的 JS 侧载体。
*
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`:
* `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。
* 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。
*/
import { describe, expect, it, vi } from 'vitest';
import { ClientActionError } from '../src/services/clientActionError';
import { loginClientWithPassword } from '../src/services/clientAuth';
import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed';
import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable';
import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed';
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 { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing';
import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected';
import type { PasswordMissing } from '../src/services/generated/PasswordMissing';
import type { PermissionDenied } from '../src/services/generated/PermissionDenied';
import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected';
import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid';
import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch';
import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed';
import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected';
import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected';
import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected';
import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired';
import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled';
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
function expectNever(value: never): void {
void value;
}
/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */
function variantMessage(error: ClientAuthError): string {
switch (error.type) {
case 'serverAddressRejected':
return (error as ServerAddressRejected).message;
case 'phoneNumberInvalid':
return (error as PhoneNumberInvalid).message;
case 'passwordMissing':
return (error as PasswordMissing).message;
case 'loginCodeMissing':
return (error as LoginCodeMissing).message;
case 'passwordEntryInputRejected':
return (error as PasswordEntryInputRejected).message;
case 'phoneOrPasswordMismatch':
return (error as PhoneOrPasswordMismatch).message;
case 'sendCodeInputRejected':
return (error as SendCodeInputRejected).message;
case 'smsCodeThrottled':
return (error as SmsCodeThrottled).message;
case 'phoneLoginInputRejected':
return (error as PhoneLoginInputRejected).message;
case 'smsCodeInvalidOrExpired':
return (error as SmsCodeInvalidOrExpired).message;
case 'sessionAuthorityRejected':
return (error as SessionAuthorityRejected).message;
case 'permissionDenied':
return (error as PermissionDenied).message;
case 'authNetworkUnavailable':
return (error as AuthNetworkUnavailable).message;
case 'authServiceUnavailable':
return (error as AuthServiceUnavailable).message;
case 'unexpectedRejection':
return (error as UnexpectedRejection).message;
case 'authResponseMalformed':
return (error as AuthResponseMalformed).message;
case 'clientSessionPersistFailed':
return (error as ClientSessionPersistFailed).message;
case 'runtimeSessionInstallFailed':
return (error as RuntimeSessionInstallFailed).message;
case 'authClientInitFailed':
return (error as AuthClientInitFailed).message;
default: {
expectNever(error);
return '';
}
}
}
function installInvoke(handler: () => Promise<unknown>) {
window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never;
}
describe('认证命令失败的 JS 侧载体', () => {
it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => {
const rejection = {
type: 'phoneNumberInvalid',
message: '请输入正确的手机号',
};
installInvoke(async () => {
throw rejection;
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ClientActionError);
expect((failure as ClientActionError).cause).toEqual(rejection);
expect((failure as ClientActionError).context).toEqual({
source: 'auth',
action: 'login_client_with_password',
});
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(ClientActionError);
expect((failure as Error).message).toBe('IPC 桥接异常');
expect((failure as ClientActionError).cause).toBe(rejection);
expect((failure as ClientActionError).context).toEqual({
source: 'auth',
action: 'login_client_with_password',
});
});
it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => {
installInvoke(async () => {
throw { type: 'unexpectedRejection', status: 409 };
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect((failure as Error).message).toBe('');
expect((failure as ClientActionError).cause).toMatchObject({
type: 'unexpectedRejection',
});
});
});
describe('变体分流', () => {
it('判别后按具名载荷类型取值', () => {
expect(
variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }),
).toBe('格式不对');
expect(
variantMessage({
type: 'authServiceUnavailable',
status: 503,
message: '服务暂不可用',
}),
).toBe('服务暂不可用');
});
});