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, []>(); const mockGetRefreshToken = jest.fn, []>(); const mockSetTokens = jest.fn, [string, string]>(); const mockClearTokens = jest.fn, []>(); 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 ( {children} ); }; } 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' }); 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')); 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); }); });