diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index fc58a3350..37dec4a95 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -251,7 +251,10 @@ export function registerAuthTests() { } if (command === 'login_client_with_password') { // Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。 - throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; + throw { + type: 'passwordLoginRejected', + serverMessage: '手机号或密码错误', + }; } return null; }); @@ -266,7 +269,9 @@ export function registerAuthTests() { }); fireEvent.click(screen.getByRole('button', { name: '登录' })); - expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect( + await screen.findByText('登录失败: 手机号或密码错误'), + ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); @@ -283,10 +288,7 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - }; + throw { type: 'authNetworkUnreachable' }; } return null; }); @@ -298,8 +300,7 @@ export function registerAuthTests() { // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + type: 'authNetworkUnreachable', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); @@ -317,8 +318,7 @@ export function registerAuthTests() { reads += 1; return reads === 1 ? Promise.reject({ - type: 'serverAddressRejected', - message: '服务器地址无效', + type: 'serverAddressNotHttps', }) : { status: 'unauthenticated' }; } @@ -326,7 +326,9 @@ export function registerAuthTests() { }); renderAuthSurface(invoke); - expect(await screen.findByText('服务器地址无效')).not.toBeNull(); + expect( + await screen.findByText('服务器地址非法: 远程地址必须使用 https'), + ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index c631d955f..338a5ff09 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -77,7 +77,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; + throw { type: 'phoneOrPasswordMismatch' }; } return null; }); @@ -86,13 +86,33 @@ describe('认证失败的上报判据', () => { expect(reportCalls()).toHaveLength(0); }); + it('服务端 400 的原文经 passwordLoginRejected 透出,仍不写错误池', async () => { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', + }; + } + return null; + }); + + expect( + await screen.findByText('登录失败: 密码长度需要在 6 到 128 位之间'), + ).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + it('发码限流同样只显示文案,不写错误池', async () => { renderLogin(async (command) => { if (command === 'read_client_auth_state') { return { status: 'unauthenticated' }; } if (command === 'send_client_phone_login_code') { - throw { type: 'smsCodeThrottled', message: '发送过于频繁,请稍后再试' }; + throw { type: 'smsCodeThrottled' }; } return null; }); @@ -102,12 +122,13 @@ describe('认证失败的上报判据', () => { }); fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); - expect(await screen.findByText('发送过于频繁,请稍后再试')).not.toBeNull(); + expect( + await screen.findByText('发送验证码过于频繁,请稍后再试'), + ).not.toBeNull(); expect(reportCalls()).toHaveLength(0); }); it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => { - const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; const uninstall = installUnhandledRejectionBridge(); try { await submitPasswordLogin(async (command) => { @@ -115,7 +136,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnavailable', message }; + throw { type: 'authNetworkUnreachable' }; } return null; }); @@ -125,7 +146,6 @@ describe('认证失败的上报判据', () => { source: 'unhandledrejection', message: '未知客户端错误', }); - expect(screen.queryByText(message)).toBeNull(); } finally { uninstall(); } diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index 51e0d77b4..156c72079 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -3,80 +3,95 @@ * 认证命令失败的 JS 侧载体。 * * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 - * 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 - * `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体, - * `expectNever(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 { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; -import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; -import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; +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 { 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 { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; 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 { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; function expectNever(value: never): void { void value; } -/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */ +/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */ function variantMessage(error: ClientAuthError): string { switch (error.type) { - case 'serverAddressRejected': - return (error as ServerAddressRejected).message; + 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 (error as PhoneNumberInvalid).message; + return '手机号格式不正确'; case 'passwordMissing': - return (error as PasswordMissing).message; + return '密码为空'; case 'loginCodeMissing': - return (error as LoginCodeMissing).message; - case 'passwordEntryInputRejected': - return (error as PasswordEntryInputRejected).message; + return '验证码为空'; + case 'passwordLoginRejected': + return (error as PasswordLoginRejected).serverMessage ?? '登录输入被拒绝'; case 'phoneOrPasswordMismatch': - return (error as PhoneOrPasswordMismatch).message; - case 'sendCodeInputRejected': - return (error as SendCodeInputRejected).message; + return '手机号或密码错误'; + case 'sendCodeRejected': + return (error as SendCodeRejected).serverMessage ?? '发码被拒绝'; 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; + return '发码过于频繁'; + case 'phoneCodeLoginRejected': + return ( + (error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝' + ); + case 'smsCodeRejected': + return '验证码错误或过期'; + case 'sessionInvalidated': + return '登录态失效'; case 'permissionDenied': - return (error as PermissionDenied).message; - case 'authNetworkUnavailable': - return (error as AuthNetworkUnavailable).message; + return '权限不足'; + case 'authNetworkTimeout': + return '连接超时'; + case 'authNetworkUnreachable': + return '连接失败'; case 'authServiceUnavailable': - return (error as AuthServiceUnavailable).message; + return `HTTP ${(error as AuthServiceUnavailable).status}`; case 'unexpectedRejection': - return (error as UnexpectedRejection).message; - case 'authResponseMalformed': - return (error as AuthResponseMalformed).message; + 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).message; + return (error as ClientSessionPersistFailed).detail; case 'runtimeSessionInstallFailed': - return (error as RuntimeSessionInstallFailed).message; + return (error as RuntimeSessionInstallFailed).detail; case 'authClientInitFailed': - return (error as AuthClientInitFailed).message; + return '网络客户端构建失败'; default: { expectNever(error); return ''; @@ -90,8 +105,8 @@ function installInvoke(handler: () => Promise) { describe('认证命令失败的 JS 侧载体', () => { it('结构化拒绝原样装进 error 字段,不读变体字段', async () => { - // 故意不带 message:包装层绝不能假设每个变体都有这个字段。 - const rejection = { type: 'phoneNumberInvalid', status: 400 }; + // 故意不带 serverMessage:包装层绝不能假设每个变体都有这个字段。 + const rejection = { type: 'phoneNumberInvalid' }; installInvoke(async () => { throw rejection; }); @@ -138,16 +153,21 @@ describe('认证命令失败的 JS 侧载体', () => { }); describe('变体分流', () => { - it('判别后按具名载荷类型取值', () => { - expect( - variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }), - ).toBe('格式不对'); + it('无字段变体直接取固定文案,带载荷变体按具名类型取值', () => { + expect(variantMessage({ type: 'phoneNumberInvalid' })).toBe( + '手机号格式不正确', + ); expect( variantMessage({ - type: 'authServiceUnavailable', - status: 503, - message: '服务暂不可用', + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', }), - ).toBe('服务暂不可用'); + ).toBe('密码长度需要在 6 到 128 位之间'); + expect( + variantMessage({ type: 'passwordLoginRejected', serverMessage: null }), + ).toBe('登录输入被拒绝'); + expect( + variantMessage({ type: 'authServiceUnavailable', status: 503 }), + ).toBe('HTTP 503'); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 9fcea5cca..660f74f4f 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -159,10 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { }); test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { - const rejection = { - type: 'authNetworkUnavailable', - message: '无法连接登录服务', - }; + const rejection = { type: 'authNetworkUnreachable' }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ error: rejection, diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index a19cd3184..1265153f0 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -210,10 +210,7 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { - const original = { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认网络后重试', - }; + const original = { type: 'authNetworkUnreachable' }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection',