// @vitest-environment jsdom /** * 登录态 façade 的边界。 * * 凭据持久化、续期 HTTP、原生会话安装都在 Rust;这里只验证渲染层提交的结构化参数、三态 * 投影映射,以及没有原生宿主时的失败关闭。 */ import { beforeEach, expect, test, vi } from 'vitest'; const invoke = vi.fn(); import { loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, refreshClientAuthSession, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const testUser = { id: 'user-1', publicUserCode: 'tn-1', displayName: '测试用户', avatarUrl: null, phoneNumber: null, phoneNumberMasked: '138****0000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, wechatDisplayName: null, wechatAccount: null, }; beforeEach(() => { invoke.mockReset(); installTauriRuntime({ core: { invoke: (...args: unknown[]) => invoke(...args) }, }); }); test('手机号归一化只保留可提交的纯号码', () => { expect(normalizeAuthPhoneInput('138 0000 0000')).toBe('13800000000'); expect(normalizeAuthPhoneInput('+8613800000000')).toBe('13800000000'); expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000'); }); test('恢复登录态映射为 authenticated / unauthenticated', async () => { invoke.mockResolvedValueOnce({ status: 'authenticated', user: testUser, apiBaseUrl: 'https://dev.genarrative.world', }); await expect( readClientAuthState('https://dev.genarrative.world'), ).resolves.toEqual({ status: 'authenticated', user: testUser, apiBaseUrl: 'https://dev.genarrative.world', }); expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { expectedApiBaseUrl: 'https://dev.genarrative.world', }); invoke.mockResolvedValueOnce({ status: 'unauthenticated' }); await expect(readClientAuthState()).resolves.toEqual({ status: 'unauthenticated', }); expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { expectedApiBaseUrl: null, }); }); test('登录只提交结构化参数,凭据由 Rust 持有', async () => { invoke.mockResolvedValue(testUser); await expect( loginClientWithPassword( '138 0000 0000', ' secret ', 'https://dev.genarrative.world', ), ).resolves.toEqual(testUser); await expect( loginClientWithPhoneCode( '13800000000', ' 123456 ', 'https://dev.genarrative.world', ), ).resolves.toEqual(testUser); await expect( sendClientPhoneLoginCode('13800000000', 'https://dev.genarrative.world'), ).resolves.toEqual({ cooldownSeconds: 0, expiresInSeconds: 0 }); await logoutClientAuthSession(); expect(invoke.mock.calls).toEqual([ [ 'login_client_with_password', { apiBaseUrl: 'https://dev.genarrative.world', phone: '13800000000', password: 'secret', }, ], [ 'login_client_with_phone_code', { apiBaseUrl: 'https://dev.genarrative.world', phone: '13800000000', code: '123456', }, ], [ 'send_client_phone_login_code', { apiBaseUrl: 'https://dev.genarrative.world', phone: '13800000000', }, ], ['logout_client_session'], ]); const serialized = JSON.stringify(invoke.mock.calls); expect(serialized).not.toContain('token'); expect(serialized).not.toContain('Authorization'); expect(serialized).not.toContain('cookie'); }); test('续期结果区分成功、失效与身份变化', async () => { invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser }); await expect(refreshClientAuthSession('user-1')).resolves.toEqual({ status: 'refreshed', user: testUser, }); invoke.mockResolvedValueOnce({ status: 'unauthenticated' }); await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'unauthenticated', }); invoke.mockResolvedValueOnce({ status: 'stale' }); await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'stale', }); }); test('没有原生宿主时登录能力明确失败关闭', async () => { resetTauriRuntime(); await expect(readClientAuthState()).rejects.toThrow( '需要在 Tauri App 内登录', ); await expect( loginClientWithPassword( '13800000000', 'secret', 'https://dev.genarrative.world', ), ).rejects.toThrow('需要在 Tauri App 内登录'); expect(invoke).not.toHaveBeenCalled(); }); test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { const rejection = { type: 'authNetworkFailure', reason: 'unreachable' }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ error: rejection, }); });