2026-03-19 01:12:17 +08:00
|
|
|
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' });
|
2026-05-19 14:31:32 +08:00
|
|
|
mockHasTokens.mockResolvedValue(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
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'));
|
2026-05-19 14:31:32 +08:00
|
|
|
mockHasTokens.mockResolvedValue(false);
|
2026-03-19 01:12:17 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|