// @vitest-environment jsdom /** * 认证命令失败的 JS 侧载体。 * * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文,载体构造时把整份载荷序列化进 * `Error.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 { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; 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 '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 (error as AuthClientInitFailed).detail; default: { expectNever(error); return ''; } } } function installInvoke(handler: () => Promise) { 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(JSON.stringify(rejection)); }); 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); // Error 没有可枚举属性,序列化就是空对象;上报链路对真 Error 优先用其自身 message/stack。 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('缺续期凭据'); }); });