import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import type { AuthModel } from 'pocketbase'; /** * Integration tests for cross-tab synchronization * Based on US5 (Cross-Tab Synchronization) from specs/001-oauth2-authentication/spec.md * * These tests verify that auth state changes in one tab synchronize to other tabs * via Pocketbase authStore.onChange() callback mechanism. * * Acceptance Criteria (US5): * - Login in tab A should update tab B within 2 seconds * - Logout in tab A should update tab B within 2 seconds * - Auth state should sync across browser windows, not just tabs */ // Mock PocketBase authStore with onChange support const mockAuthStore = { isValid: false, record: null as AuthModel | null, clear: vi.fn(), onChange: vi.fn(), }; const mockCollection = vi.fn(); const mockAuthWithOAuth2 = vi.fn(); const mockListAuthMethods = vi.fn(); const mockAuthRefresh = vi.fn(); vi.mock('../usePocketbase', () => ({ usePocketbase: () => ({ authStore: mockAuthStore, collection: mockCollection, }), })); // Mock router using Nuxt's test utils const mockRouterPush = vi.fn(); const mockRouter = { push: mockRouterPush, }; mockNuxtImport('useRouter', () => { return () => mockRouter; }); describe('useAuth - Cross-Tab Synchronization', () => { beforeEach(async () => { // Reset all mocks vi.clearAllMocks(); mockAuthStore.isValid = false; mockAuthStore.record = null; // Setup default mock implementations mockCollection.mockReturnValue({ authWithOAuth2: mockAuthWithOAuth2, listAuthMethods: mockListAuthMethods, authRefresh: mockAuthRefresh, }); // Clear module cache to get fresh imports vi.resetModules(); }); describe('Login Synchronization', () => { it('should update user.value when authStore onChange fires with new user', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Initialize auth (sets up onChange listener) await auth.initAuth(); // Verify initial state expect(auth.user.value).toBeNull(); expect(auth.isAuthenticated.value).toBe(false); // Get the onChange callback that was registered const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; expect(onChangeCallback).toBeDefined(); // Simulate login event in another tab (authStore onChange fires) const newUser: AuthModel = { id: 'user123', email: 'test@example.com', name: 'Test User', verified: true, created: new Date().toISOString(), updated: new Date().toISOString(), } as unknown as AuthModel; mockAuthStore.isValid = true; mockAuthStore.record = newUser; onChangeCallback('token123', newUser); // Verify user state updated expect(auth.user.value).toEqual(newUser); }); it('should update isAuthenticated when authStore onChange fires with valid user', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Initialize auth await auth.initAuth(); expect(auth.isAuthenticated.value).toBe(false); // Get the onChange callback const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate login in another tab const newUser: AuthModel = { id: 'user456', email: 'another@example.com', name: 'Another User', } as unknown as AuthModel; mockAuthStore.isValid = true; mockAuthStore.record = newUser; onChangeCallback('token456', newUser); // Verify authenticated state expect(auth.isAuthenticated.value).toBe(true); expect(auth.user.value?.email).toBe('another@example.com'); }); it('should handle rapid login events from multiple tabs', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate rapid login events (edge case: multiple tabs logging in) const user1: AuthModel = { id: 'user1', email: 'user1@example.com', } as unknown as AuthModel; const user2: AuthModel = { id: 'user2', email: 'user2@example.com', } as unknown as AuthModel; mockAuthStore.isValid = true; // First login event mockAuthStore.record = user1; onChangeCallback('token1', user1); expect(auth.user.value?.id).toBe('user1'); // Second login event (should overwrite) mockAuthStore.record = user2; onChangeCallback('token2', user2); expect(auth.user.value?.id).toBe('user2'); }); }); describe('Logout Synchronization', () => { it('should clear user.value when authStore onChange fires with null', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Start with authenticated user const initialUser: AuthModel = { id: 'user123', email: 'test@example.com', } as unknown as AuthModel; mockAuthStore.record = initialUser; mockAuthStore.isValid = true; await auth.initAuth(); expect(auth.user.value).toEqual(initialUser); expect(auth.isAuthenticated.value).toBe(true); // Get the onChange callback const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate logout event in another tab (authStore onChange fires with null) mockAuthStore.isValid = false; mockAuthStore.record = null; onChangeCallback('', null); // Verify user state cleared expect(auth.user.value).toBeNull(); }); it('should update isAuthenticated to false when authStore onChange fires with null', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Start authenticated mockAuthStore.record = { id: 'user123', email: 'test@example.com', } as unknown as AuthModel; mockAuthStore.isValid = true; await auth.initAuth(); expect(auth.isAuthenticated.value).toBe(true); // Get the onChange callback const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate logout in another tab mockAuthStore.isValid = false; mockAuthStore.record = null; onChangeCallback('', null); // Verify not authenticated expect(auth.isAuthenticated.value).toBe(false); }); it('should handle logout after login in same session', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate login const user: AuthModel = { id: 'user123', email: 'test@example.com', } as unknown as AuthModel; mockAuthStore.isValid = true; mockAuthStore.record = user; onChangeCallback('token123', user); expect(auth.isAuthenticated.value).toBe(true); // Simulate logout mockAuthStore.isValid = false; mockAuthStore.record = null; onChangeCallback('', null); expect(auth.isAuthenticated.value).toBe(false); expect(auth.user.value).toBeNull(); }); }); describe('onChange Listener Registration', () => { it('should register onChange listener exactly once during initAuth', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); expect(mockAuthStore.onChange).not.toHaveBeenCalled(); await auth.initAuth(); expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1); expect(mockAuthStore.onChange).toHaveBeenCalledWith(expect.any(Function)); }); it('should not register duplicate onChange listeners on multiple initAuth calls', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); await auth.initAuth(); await auth.initAuth(); // onChange should be called once per initAuth call // (This is acceptable behavior - Pocketbase handles duplicates) expect(mockAuthStore.onChange).toHaveBeenCalledTimes(3); }); it('should register onChange callback with correct signature', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Callback should accept token (string) and model (AuthModel | null) expect(typeof onChangeCallback).toBe('function'); expect(onChangeCallback.length).toBe(2); // (token, model) => void }); }); describe('Cross-Tab Edge Cases', () => { it('should handle authStore onChange with undefined model', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; // Simulate edge case: authStore fires onChange with undefined mockAuthStore.isValid = false; mockAuthStore.record = null; onChangeCallback('', undefined); // Should handle gracefully (treat as logout) expect(auth.user.value).toBeNull(); expect(auth.isAuthenticated.value).toBe(false); }); it('should handle authStore onChange during active login flow', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); await auth.initAuth(); // Start login flow (simulates user clicking login in this tab) mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: true, providers: [ { name: 'google', displayName: 'Google', state: 'state123', codeVerifier: 'verifier', codeChallenge: 'challenge', codeChallengeMethod: 'S256', authURL: 'https://google.com/oauth', }, ], }, }); const loginUser: AuthModel = { id: 'loginUser', email: 'login@example.com', } as unknown as AuthModel; mockAuthWithOAuth2.mockResolvedValue({ record: loginUser, }); // Start login const loginPromise = auth.login('google'); // While login is pending, simulate onChange from another tab const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; const crossTabUser: AuthModel = { id: 'crossTabUser', email: 'crosstab@example.com', } as unknown as AuthModel; mockAuthStore.isValid = true; mockAuthStore.record = crossTabUser; onChangeCallback('token999', crossTabUser); // User should be updated from cross-tab event expect(auth.user.value?.id).toBe('crossTabUser'); // Wait for login to complete await loginPromise; // After login completes, user should be from login flow expect(auth.user.value?.id).toBe('loginUser'); }); it('should synchronize user profile updates from another tab', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Start with user const initialUser: AuthModel = { id: 'user123', email: 'test@example.com', name: 'Old Name', } as unknown as AuthModel; mockAuthStore.record = initialUser; mockAuthStore.isValid = true; await auth.initAuth(); expect(auth.user.value?.name).toBe('Old Name'); // Simulate user profile update in another tab const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; const updatedUser: AuthModel = { id: 'user123', email: 'test@example.com', name: 'Updated Name', } as unknown as AuthModel; mockAuthStore.record = updatedUser; onChangeCallback('token123', updatedUser); // Verify profile update synced expect(auth.user.value?.name).toBe('Updated Name'); expect(auth.user.value?.id).toBe('user123'); // Same user }); }); describe('Session Restoration and Sync Integration', () => { it('should restore user from authStore and setup onChange listener', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); const existingUser: AuthModel = { id: 'existing123', email: 'existing@example.com', } as unknown as AuthModel; mockAuthStore.record = existingUser; mockAuthStore.isValid = true; // initAuth should both restore and setup listener await auth.initAuth(); // Verify restoration expect(auth.user.value).toEqual(existingUser); // Verify listener setup expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1); // Verify listener works const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; const newUser: AuthModel = { id: 'new456', email: 'new@example.com', } as unknown as AuthModel; mockAuthStore.record = newUser; onChangeCallback('token456', newUser); expect(auth.user.value?.id).toBe('new456'); }); it('should handle case where authStore is empty on init but login happens in another tab', async () => { const { useAuth } = await import('../useAuth'); const auth = useAuth(); // Start with empty authStore (user logged out or first load) mockAuthStore.record = null; mockAuthStore.isValid = false; await auth.initAuth(); expect(auth.isAuthenticated.value).toBe(false); // Simulate login in another tab const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; const user: AuthModel = { id: 'user789', email: 'user789@example.com', } as unknown as AuthModel; mockAuthStore.isValid = true; mockAuthStore.record = user; onChangeCallback('token789', user); // Should sync login from other tab expect(auth.isAuthenticated.value).toBe(true); expect(auth.user.value?.id).toBe('user789'); }); }); });