import { useEffect } from 'react'; import { act, render, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { AuthProvider, useAuth } from '../AuthContext'; const { loginMock, registerMock, logoutMock, restoreSessionMock, getGoogleOAuthUrlMock, exchangeGoogleCodeMock, } = vi.hoisted(() => ({ loginMock: vi.fn(), registerMock: vi.fn(), logoutMock: vi.fn(), restoreSessionMock: vi.fn(() => null), getGoogleOAuthUrlMock: vi.fn(), exchangeGoogleCodeMock: vi.fn(), })); vi.mock('../../lib/authService', () => ({ authService: { login: loginMock, register: registerMock, logout: logoutMock, restoreSession: restoreSessionMock, getGoogleOAuthUrl: getGoogleOAuthUrlMock, exchangeGoogleCode: exchangeGoogleCodeMock, }, })); function Harness({ onReady }: { onReady: (ctx: ReturnType) => void }) { const auth = useAuth(); useEffect(() => { onReady(auth); }, [auth, onReady]); return null; } function renderWithProvider(onReady: (ctx: ReturnType) => void) { return render( ); } describe('AuthContext OAuth flow', () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); localStorage.clear(); restoreSessionMock.mockReturnValue(null); }); it('beginGoogleOAuth writes state and redirect then redirects browser', async () => { getGoogleOAuthUrlMock.mockResolvedValue({ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth' }); let ctxRef: ReturnType | null = null; renderWithProvider((ctx) => { ctxRef = ctx; }); await waitFor(() => { expect(ctxRef).toBeTruthy(); }); await act(async () => { await (ctxRef as ReturnType).beginGoogleOAuth(); }); expect(sessionStorage.getItem('texpixel_oauth_state')).toBeTruthy(); expect(sessionStorage.getItem('texpixel_post_login_redirect')).toBe(window.location.href); expect(getGoogleOAuthUrlMock).toHaveBeenCalledTimes(1); }); it('completeGoogleOAuth rejects when state mismatches', async () => { sessionStorage.setItem('texpixel_oauth_state', 'expected_state'); let ctxRef: ReturnType | null = null; renderWithProvider((ctx) => { ctxRef = ctx; }); await waitFor(() => { expect(ctxRef).toBeTruthy(); }); let result: { error: Error | null } = { error: null }; await act(async () => { result = await (ctxRef as ReturnType).completeGoogleOAuth({ code: 'abc', state: 'wrong_state', redirect_uri: 'http://localhost:5173/auth/google/callback', }); }); expect(result.error).toBeTruthy(); expect(exchangeGoogleCodeMock).not.toHaveBeenCalled(); expect(localStorage.getItem('texpixel_token')).toBeNull(); }); it('completeGoogleOAuth stores session on success', async () => { sessionStorage.setItem('texpixel_oauth_state', 'state_ok'); exchangeGoogleCodeMock.mockImplementation(async () => { localStorage.setItem('texpixel_token', 'Bearer header.payload.sig'); return { token: 'Bearer header.payload.sig', expiresAt: 1999999999, user: { user_id: 7, id: '7', email: 'oauth@example.com', exp: 1999999999, iat: 1111111, }, }; }); let ctxRef: ReturnType | null = null; renderWithProvider((ctx) => { ctxRef = ctx; }); await waitFor(() => { expect(ctxRef).toBeTruthy(); }); let result: { error: Error | null } = { error: null }; await act(async () => { result = await (ctxRef as ReturnType).completeGoogleOAuth({ code: 'code_ok', state: 'state_ok', redirect_uri: 'http://localhost:5173/auth/google/callback', }); }); expect(result.error).toBeNull(); await waitFor(() => { expect((ctxRef as ReturnType).isAuthenticated).toBe(true); }); expect(localStorage.getItem('texpixel_token')).toBe('Bearer header.payload.sig'); }); it('completeGoogleOAuth deduplicates same code requests', async () => { sessionStorage.setItem('texpixel_oauth_state', 'state_ok'); exchangeGoogleCodeMock.mockImplementation( () => new Promise((resolve) => { setTimeout(() => { localStorage.setItem('texpixel_token', 'Bearer header.payload.sig'); resolve({ token: 'Bearer header.payload.sig', expiresAt: 1999999999, user: { user_id: 7, id: '7', email: 'oauth@example.com', exp: 1999999999, iat: 1111111, }, }); }, 20); }) ); let ctxRef: ReturnType | null = null; renderWithProvider((ctx) => { ctxRef = ctx; }); await waitFor(() => { expect(ctxRef).toBeTruthy(); }); let result1: { error: Error | null } = { error: null }; let result2: { error: Error | null } = { error: null }; await act(async () => { const p1 = (ctxRef as ReturnType).completeGoogleOAuth({ code: 'same_code', state: 'state_ok', redirect_uri: 'http://localhost:5173/auth/google/callback', }); const p2 = (ctxRef as ReturnType).completeGoogleOAuth({ code: 'same_code', state: 'state_ok', redirect_uri: 'http://localhost:5173/auth/google/callback', }); [result1, result2] = await Promise.all([p1, p2]); }); expect(result1.error).toBeNull(); expect(result2.error).toBeNull(); expect(exchangeGoogleCodeMock).toHaveBeenCalledTimes(1); }); });