e43efeb269
- 删除 clientAuthFailure.ts,invokeClientAuth 改为把拒绝装进已有 ClientActionError,cause 即 ts-rs 生成的 ClientAuthError - 三处 catch 改为 instanceof ClientActionError 后取 error.cause 分流,每个 case 用 as 取具名载荷 - 认证载体用例改名 clientAuthErrorCarrier.test.ts,断言改为 ClientActionError.cause - clientAuthHost 用例与 auth_session.rs 注释同步新载体
180 lines
7.3 KiB
TypeScript
180 lines
7.3 KiB
TypeScript
// @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('服务暂不可用');
|
||
});
|
||
});
|