Files
life-echo/app-expo/tests/features/auth/hooks.test.tsx
Kevin b22f1cd4c4 feat(app-expo): replay brand splash on logout and route to login
After sign-out or data purge, clear session state reliably, remount the
splash overlay above navigation, and navigate to login instead of tabs so
users no longer briefly land on the chat home screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:31:32 +08:00

276 lines
7.6 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor, act } from '@testing-library/react-native';
import React, { type PropsWithChildren } from 'react';
import { AuthError } from '@/core/api/types';
import {
useSession,
useLogin,
useLogout,
authKeys,
} from '@/features/auth/hooks';
// ─── Mocks ───
const mockHasTokens = jest.fn<Promise<boolean>, []>();
const mockGetRefreshToken = jest.fn<Promise<string | null>, []>();
const mockSetTokens = jest.fn<Promise<void>, [string, string]>();
const mockClearTokens = jest.fn<Promise<void>, []>();
jest.mock('@/core/auth/token-manager', () => ({
tokenManager: {
hasTokens: () => mockHasTokens(),
getAccessToken: jest.fn().mockResolvedValue('access-token'),
getRefreshToken: () => mockGetRefreshToken(),
setTokens: (...args: [string, string]) => mockSetTokens(...args),
clearTokens: () => mockClearTokens(),
},
}));
const mockFetchMe = jest.fn();
const mockLogin = jest.fn();
const mockLogout = jest.fn();
jest.mock('@/features/auth/api', () => ({
authApi: {
fetchMe: () => mockFetchMe(),
login: (body: unknown) => mockLogin(body),
logout: (token: string) => mockLogout(token),
},
}));
// ─── Helpers ───
let queryClient: QueryClient;
function createWrapper() {
queryClient = new QueryClient({
defaultOptions: {
queries: { gcTime: Infinity, retryDelay: 0 },
mutations: { retry: false },
},
});
return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
afterEach(async () => {
await queryClient?.cancelQueries();
queryClient?.clear();
jest.clearAllMocks();
});
const fakeUser = {
id: '1',
phone: '13800138000',
email: null,
nickname: 'Test',
avatar_url: null,
subscription_type: 'free',
created_at: '2026-01-01T00:00:00Z',
};
// ─── useSession ───
describe('useSession', () => {
test('returns loading while tokenCheck is pending', () => {
mockHasTokens.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
expect(result.current.status).toBe('loading');
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
test('returns unauthenticated when no tokens exist', async () => {
mockHasTokens.mockResolvedValue(false);
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.status).toBe('unauthenticated');
});
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
expect(mockFetchMe).not.toHaveBeenCalled();
});
test('returns authenticated when tokens exist and /me succeeds', async () => {
mockHasTokens.mockResolvedValue(true);
mockFetchMe.mockResolvedValue(fakeUser);
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.status).toBe('authenticated');
});
expect(result.current.user).toEqual(fakeUser);
expect(result.current.isAuthenticated).toBe(true);
});
test('returns unauthenticated when /me throws AuthError', async () => {
mockHasTokens.mockResolvedValue(true);
mockFetchMe.mockRejectedValue(new AuthError());
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.status).toBe('unauthenticated');
});
expect(result.current.isAuthenticated).toBe(false);
});
test('returns authenticated (optimistic) when /me throws non-auth error', async () => {
mockHasTokens.mockResolvedValue(true);
mockFetchMe.mockRejectedValue(new Error('Server 500'));
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(
() => {
expect(result.current.status).toBe('authenticated');
},
{ timeout: 5000 },
);
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeNull();
});
test('returns authenticated (optimistic) when tokens exist and /me is loading', async () => {
mockHasTokens.mockResolvedValue(true);
mockFetchMe.mockReturnValue(new Promise(() => {})); // never resolves
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.status).toBe('authenticated');
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeNull();
});
test('returns error when tokenCheck itself fails (SecureStore crash)', async () => {
mockHasTokens.mockRejectedValue(new Error('SecureStore unavailable'));
const { result } = renderHook(() => useSession(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.status).toBe('error');
});
expect(result.current.isAuthenticated).toBe(false);
expect(mockFetchMe).not.toHaveBeenCalled();
});
});
// ─── useLogin ───
describe('useLogin', () => {
test('sets tokens and updates tokenCheck cache on success', async () => {
const tokens = {
access_token: 'new-access',
refresh_token: 'new-refresh',
token_type: 'bearer',
};
mockLogin.mockResolvedValue(tokens);
mockHasTokens.mockResolvedValue(false);
const wrapper = createWrapper();
const { result } = renderHook(() => useLogin(), { wrapper });
await act(async () => {
result.current.mutate({
phone: '13800138000',
password: '123456',
agreed_to_terms: true,
});
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockSetTokens).toHaveBeenCalledWith('new-access', 'new-refresh');
expect(queryClient.getQueryData(authKeys.tokenCheck)).toBe(true);
});
});
// ─── useLogout ───
describe('useLogout', () => {
test('reads refresh token first, calls API, then clears local state', async () => {
mockGetRefreshToken.mockResolvedValue('my-refresh');
mockLogout.mockResolvedValue({ message: 'ok' });
mockHasTokens.mockResolvedValue(false);
const wrapper = createWrapper();
queryClient.setQueryData(authKeys.tokenCheck, true);
queryClient.setQueryData(authKeys.session, fakeUser);
const { result } = renderHook(() => useLogout(), { wrapper });
await act(async () => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockGetRefreshToken).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalledWith('my-refresh');
expect(mockClearTokens).toHaveBeenCalled();
expect(queryClient.getQueryData(authKeys.tokenCheck)).toBe(false);
});
test('clears local state even if server logout fails', async () => {
mockGetRefreshToken.mockResolvedValue('my-refresh');
mockLogout.mockRejectedValue(new Error('Network down'));
mockHasTokens.mockResolvedValue(false);
const wrapper = createWrapper();
queryClient.setQueryData(authKeys.tokenCheck, true);
queryClient.setQueryData(authKeys.session, fakeUser);
const { result } = renderHook(() => useLogout(), { wrapper });
await act(async () => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockClearTokens).toHaveBeenCalled();
expect(queryClient.getQueryData(authKeys.tokenCheck)).toBe(false);
});
});