/** * session.spec.js * * Cobre o módulo de sessão global — foco nas race conditions documentadas * no próprio session.js (singleflight, SIGNED_IN redundante, TOKEN_REFRESHED). * * Mock do supabase: capturamos o callback de onAuthStateChange pra disparar * eventos manualmente e observar o state dos refs reativos. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Captura do callback de onAuthStateChange (setado no listenAuthChanges) let authCallback = null; // Mock configurável de getSession (pode trocar em cada teste via mockImplementation) const getSessionMock = vi.fn(); const profileSingleMock = vi.fn(); const saasMaybeSingleMock = vi.fn(); vi.mock('@/lib/supabase/client', () => { const from = vi.fn((table) => { return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), maybeSingle: table === 'saas_admins' ? saasMaybeSingleMock : profileSingleMock, single: vi.fn().mockResolvedValue({ data: null, error: null }) }; }); return { supabase: { auth: { getSession: getSessionMock, onAuthStateChange: vi.fn((cb) => { authCallback = cb; return { data: { subscription: { unsubscribe: vi.fn() } } }; }) }, from } }; }); vi.mock('@/support/supportLogger', () => ({ logAuth: vi.fn(), logError: vi.fn() })); // Importa depois dos mocks const session = await import('../session.js'); beforeEach(() => { // Reseta state dos refs e mocks a cada teste session.sessionUser.value = null; session.sessionRole.value = null; session.sessionIsSaasAdmin.value = false; session.sessionReady.value = false; session.sessionRefreshing.value = false; // Desfaz listenAuthChanges de teste anterior pra permitir re-registro session.stopAuthChanges(); authCallback = null; getSessionMock.mockReset(); profileSingleMock.mockReset(); saasMaybeSingleMock.mockReset(); // defaults razoáveis profileSingleMock.mockResolvedValue({ data: { role: 'therapist' }, error: null }); saasMaybeSingleMock.mockResolvedValue({ data: null, error: null }); }); describe('initSession — boot inicial', () => { it('sem sessão → zera user/role/saasAdmin', async () => { getSessionMock.mockResolvedValue({ data: { session: null }, error: null }); await session.initSession({ initial: true }); expect(session.sessionUser.value).toBe(null); expect(session.sessionRole.value).toBe(null); expect(session.sessionIsSaasAdmin.value).toBe(false); expect(session.sessionReady.value).toBe(true); expect(session.sessionRefreshing.value).toBe(false); }); it('com sessão → hydrata user + busca role', async () => { getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'uid-1' } } }, error: null }); profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null }); await session.initSession({ initial: true }); expect(session.sessionUser.value?.id).toBe('uid-1'); expect(session.sessionRole.value).toBe('clinic_admin'); expect(session.sessionReady.value).toBe(true); }); it('erro em getSession → state zerado (não propaga)', async () => { getSessionMock.mockRejectedValue(new Error('network down')); await session.initSession({ initial: true }); expect(session.sessionUser.value).toBe(null); expect(session.sessionRole.value).toBe(null); expect(session.sessionReady.value).toBe(true); // ainda marca ready pra não travar o guard }); it('singleflight: 2 chamadas concorrentes fazem apenas 1 getSession', async () => { let resolveGet; getSessionMock.mockImplementation(() => new Promise((resolve) => { resolveGet = resolve; })); const p1 = session.initSession({ initial: true }); const p2 = session.initSession({ initial: true }); // segunda chamada deve ter retornado a mesma promise (sem disparar getSession de novo) expect(getSessionMock).toHaveBeenCalledTimes(1); resolveGet({ data: { session: null }, error: null }); await Promise.all([p1, p2]); }); }); describe('refreshSession — evita corrida', () => { it('não dispara se já está refreshing', async () => { session.sessionRefreshing.value = true; await session.refreshSession(); expect(getSessionMock).not.toHaveBeenCalled(); }); it('sem sessão → não zera state existente (SIGNED_OUT cuida)', async () => { session.sessionUser.value = { id: 'uid-1' }; session.sessionRole.value = 'therapist'; getSessionMock.mockResolvedValue({ data: { session: null }, error: null }); await session.refreshSession(); // State preservado — refreshSession não é quem zera (é SIGNED_OUT) expect(session.sessionUser.value?.id).toBe('uid-1'); expect(session.sessionRole.value).toBe('therapist'); }); it('mesma sessão consistente → no-op', async () => { session.sessionUser.value = { id: 'uid-1' }; session.sessionRole.value = 'therapist'; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'uid-1' } } }, error: null }); await session.refreshSession(); // initSession não foi chamado de novo (state já era consistente) expect(getSessionMock).toHaveBeenCalledTimes(1); // só o refreshSession próprio }); }); describe('listenAuthChanges — callbacks de auth', () => { it('SIGNED_OUT zera state + chama callback', async () => { const onOut = vi.fn(); session.setOnSignedOut(onOut); session.listenAuthChanges(); // simula state previamente hydratado session.sessionUser.value = { id: 'uid-1' }; session.sessionRole.value = 'therapist'; session.sessionIsSaasAdmin.value = true; session.sessionRefreshing.value = true; expect(authCallback).toBeTypeOf('function'); await authCallback('SIGNED_OUT', null); expect(session.sessionUser.value).toBe(null); expect(session.sessionRole.value).toBe(null); expect(session.sessionIsSaasAdmin.value).toBe(false); expect(session.sessionRefreshing.value).toBe(false); expect(session.sessionReady.value).toBe(true); expect(onOut).toHaveBeenCalledTimes(1); }); it('SIGNED_IN com mesmo user (redundante) é ignorado', async () => { session.listenAuthChanges(); session.sessionUser.value = { id: 'uid-1' }; session.sessionRole.value = 'therapist'; session.sessionReady.value = true; await authCallback('SIGNED_IN', { user: { id: 'uid-1' } }); // Não rehidratou — profileSingleMock não foi chamado expect(profileSingleMock).not.toHaveBeenCalled(); }); it('SIGNED_IN com user diferente → hydrata novo', async () => { session.listenAuthChanges(); session.sessionUser.value = { id: 'uid-1' }; session.sessionRole.value = 'therapist'; session.sessionReady.value = true; profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null }); await authCallback('SIGNED_IN', { user: { id: 'uid-2' } }); expect(session.sessionUser.value?.id).toBe('uid-2'); expect(session.sessionRole.value).toBe('clinic_admin'); }); }); describe('stopAuthChanges — cleanup', () => { it('unsubscribe é chamado', () => { session.listenAuthChanges(); session.stopAuthChanges(); // não deve lançar erro se chamar de novo expect(() => session.stopAuthChanges()).not.toThrow(); }); });