import AsyncStorage from '@react-native-async-storage/async-storage' import { QueryClientProvider } from '@tanstack/react-query' import { renderHook, act } from '@testing-library/react-native' import React, { useContext, Suspense } from 'react' import { buildTestQueryClient } from '../../__utils__/query_client' import { SessionContext, SessionContextProvider, SessionContextConfig, } from '../../contexts/session_context' import { OAuthToken, FailedResponse } from '../../types' import { StorageAdapter } from '../../utils/native_adapters/storage_adapter' import { Session } from '../../utils/session' import { SessionManager } from '../../utils/session_manager' jest.mock('@react-native-async-storage/async-storage') // Mock chatQueryClient to prevent CancelledError in tests jest.mock('../../contexts/api_provider', () => { const mockChatQueryClient = { clear: jest.fn(), removeQueries: jest.fn(), getQueryCache: jest.fn(() => ({ subscribe: jest.fn(() => jest.fn()), })), getQueryData: jest.fn(), setQueryData: jest.fn(), fetchQuery: jest.fn().mockResolvedValue({ data: null }), } return { chatQueryClient: mockChatQueryClient, } }) jest.useFakeTimers() const mockAsyncStorage = AsyncStorage as jest.Mocked afterAll(() => { jest.useRealTimers() }) let mockSecureStorage: StorageAdapter let mockRefreshTokenFn: jest.Mock let mockOnLogout: jest.Mock const createMockStorage = (): StorageAdapter => { const storage: Record = {} return new StorageAdapter({ getItem: async (key: string) => { return storage[key] || null }, setItem: async (key: string, value: string) => { storage[key] = value }, removeItem: async (key: string) => { delete storage[key] }, }) } // Helper to wait for Suspense and async operations to resolve const waitForQuery = async () => { await act(async () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() }) } const createWrapper = (config: SessionContextConfig) => { const queryClient = buildTestQueryClient() return ({ children }: { children: React.ReactNode }) => ( {children} ) } beforeEach(() => { mockSecureStorage = createMockStorage() mockRefreshTokenFn = jest.fn() mockOnLogout = jest.fn() // Reset AsyncStorage mocks with a storage implementation const asyncStorageData: Record = {} mockAsyncStorage.getItem.mockImplementation(async (key: string) => { return asyncStorageData[key] || null }) mockAsyncStorage.setItem.mockImplementation(async (key: string, value: string) => { asyncStorageData[key] = value }) mockAsyncStorage.removeItem.mockImplementation(async (key: string) => { delete asyncStorageData[key] }) }) afterEach(() => { jest.restoreAllMocks() }) const TEST_SESSIONS_KEY = 'test-sessions-storage' const createConfig = (overrides?: Partial): SessionContextConfig => ({ secureStorage: mockSecureStorage, refreshTokenFn: mockRefreshTokenFn, onLogout: mockOnLogout, storageKeys: { sessions: TEST_SESSIONS_KEY, }, ...overrides, }) const useSessionContext = () => { return useContext(SessionContext) } describe('SessionContextProvider', () => { describe('initialization', () => { it('should initialize with default environment', async () => { const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() expect(result.current.env).toBe('production') expect(result.current.token).toBeUndefined() }) it('should use custom default environment', async () => { // Note: defaultEnv is not currently used in the implementation // This test verifies the current behavior (defaults to 'production') const config = createConfig({ defaultEnv: 'staging' }) const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() // Currently defaults to 'production' regardless of defaultEnv config expect(result.current.env).toBe('production') }) }) describe('logout', () => { it('should clear sessions and call onLogout', async () => { const token: OAuthToken = { access_token: 'test-token-12345678', refresh_token: 'test-refresh', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } // Set up a session with a token (keyed by last 8 chars of token) const session = new Session({ env: 'production', token }) const manager = new SessionManager() manager.upsertSession(session) await mockSecureStorage.setItem(TEST_SESSIONS_KEY, JSON.stringify(manager.toString())) const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() expect(result.current.token).toBeDefined() // Logout await act(async () => { await result.current.logout() }) await waitForQuery() await waitForQuery() // Extra wait for async operations expect(mockOnLogout).toHaveBeenCalledTimes(1) }) it('should clear query clients on logout', async () => { const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() // Note: We can't spy on the queryClient since it's created inside createWrapper // But we can verify logout was called await act(async () => { await result.current.logout() }) expect(mockOnLogout).toHaveBeenCalled() }) }) describe('token refresh', () => { it('should refresh token when handleUnauthorizedResponse is called with expired token', async () => { const token: OAuthToken = { access_token: 'old-token-12345678', refresh_token: 'refresh-token', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } const newToken: OAuthToken = { access_token: 'new-token-87654321', refresh_token: 'new-refresh-token', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } mockRefreshTokenFn.mockResolvedValue(newToken) const session = new Session({ env: 'production', token }) const manager = new SessionManager() manager.upsertSession(session) await mockSecureStorage.setItem(TEST_SESSIONS_KEY, JSON.stringify(manager.toString())) const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() expect(result.current.token).toBeDefined() const failedResponse: FailedResponse = { status: 401, statusText: 'Unauthorized', errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }], } as FailedResponse await act(async () => { result.current.handleUnauthorizedResponse(failedResponse) }) await waitForQuery() expect(mockRefreshTokenFn).toHaveBeenCalledWith({ refresh_token: 'refresh-token', env: 'production', }) }) it('should handle multiple unauthorized responses', async () => { const token: OAuthToken = { access_token: 'old-token-12345678', refresh_token: 'refresh-token', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } const newToken: OAuthToken = { access_token: 'new-token-87654321', refresh_token: 'new-refresh-token', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } mockRefreshTokenFn.mockResolvedValue(newToken) const session = new Session({ env: 'production', token }) const manager = new SessionManager() manager.upsertSession(session) await mockSecureStorage.setItem(TEST_SESSIONS_KEY, JSON.stringify(manager.toString())) const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() expect(result.current.token).toBeDefined() const failedResponse: FailedResponse = { status: 401, statusText: 'Unauthorized', errors: [{ detail: 'other-error', title: 'Unauthorized', status: '401' }], } as FailedResponse // Call handleUnauthorizedResponse - should trigger refresh await act(async () => { result.current.handleUnauthorizedResponse(failedResponse) }) await waitForQuery() // Verify refresh was called expect(mockRefreshTokenFn).toHaveBeenCalled() }) it('should logout on forced logout error (capuchin)', async () => { const token: OAuthToken = { access_token: 'old-token-12345678', refresh_token: 'refresh-token', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } const session = new Session({ env: 'production', token }) const manager = new SessionManager() manager.upsertSession(session) await mockSecureStorage.setItem(TEST_SESSIONS_KEY, JSON.stringify(manager.toString())) mockRefreshTokenFn.mockRejectedValue({ status: 401, errors: [{ detail: 'capuchin', title: 'Forced Logout', status: '401' }], }) const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() expect(result.current.token).toBeDefined() // Trigger refresh that will fail with capuchin const failedResponse: FailedResponse = { status: 401, statusText: 'Unauthorized', errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }], } as FailedResponse await act(async () => { result.current.handleUnauthorizedResponse(failedResponse) }) // Wait for mutation to execute and fail await waitForQuery() await waitForQuery() await waitForQuery() expect(mockRefreshTokenFn).toHaveBeenCalled() // Wait for onError handler to process and call logout await waitForQuery() await waitForQuery() await waitForQuery() expect(mockOnLogout).toHaveBeenCalled() }) it('should ignore TRASH_PANDA errors', async () => { const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() const failedResponse: FailedResponse = { status: 401, statusText: 'Unauthorized', errors: [{ detail: 'TRASH_PANDA', title: 'Unauthorized', status: '401' }], } as FailedResponse await act(async () => { result.current.handleUnauthorizedResponse(failedResponse) }) await waitForQuery() expect(mockRefreshTokenFn).not.toHaveBeenCalled() }) }) describe('setToken', () => { it('should update token in storage', async () => { const config = createConfig() const { result } = renderHook(() => useSessionContext(), { wrapper: createWrapper(config), }) await waitForQuery() const newToken: OAuthToken = { access_token: 'new-token-12345678', refresh_token: 'new-refresh', expires_in: 3600, token_type: 'Bearer', scope: 'read write', created_at: Date.now(), } await act(async () => { await result.current.setToken(newToken) }) const storedValue = await mockSecureStorage.getItem(TEST_SESSIONS_KEY) expect(storedValue).toBeTruthy() const managerString = JSON.parse(storedValue!) const manager = SessionManager.hydrate(managerString) expect(manager.currentSession.token?.access_token).toBe('new-token-12345678') }) }) })