diff --git a/src/app/dashboard/planos/planos-client.test.tsx b/src/app/dashboard/planos/planos-client.test.tsx index 055b3b61..8a1de938 100644 --- a/src/app/dashboard/planos/planos-client.test.tsx +++ b/src/app/dashboard/planos/planos-client.test.tsx @@ -206,6 +206,62 @@ describe('PlanosClient', () => { expect(screen.queryAllByTestId('card')).toHaveLength(0); }); + it('opens form in edit mode when "Editar" is clicked', () => { + render(); + const editButtons = screen.getAllByText('P'); + fireEvent.click(editButtons[0]); + expect(screen.getByTestId('form-plano')).toBeTruthy(); + expect(screen.getByText('Editando')).toBeTruthy(); + }); + + it('calls updatePlanoAction on edit submit', async () => { + const { updatePlanoAction } = await import('@/lib/actions/planos'); + vi.mocked(updatePlanoAction).mockResolvedValue({ + success: true, + data: { id: 'plano-1', nome: 'Mensal', preco: 100, duracaoDias: 30 }, + }); + + render(); + fireEvent.click(screen.getAllByText('P')[0]); + fireEvent.click(screen.getByTestId('submit-plano')); + + await waitFor(() => { + expect(updatePlanoAction).toHaveBeenCalled(); + expect(mockNotify.success).toHaveBeenCalledWith('Plano atualizado!', expect.any(String)); + }); + }); + + it('shows error on delete failure', async () => { + const { deletePlanoAction } = await import('@/lib/actions/planos'); + vi.mocked(deletePlanoAction).mockResolvedValue({ success: false, error: 'Erro ao excluir' }); + + render(); + fireEvent.click(screen.getAllByText('Excluir')[0]); + fireEvent.click(screen.getAllByText('Excluir')[screen.getAllByText('Excluir').length - 1]); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalledWith('Erro ao excluir', 'Erro ao excluir'); + }); + }); + + it('displays "mês" badge when duration is exactly 30 days', () => { + const customPlanos: Plano[] = [{ id: 'p2', nome: 'Mensal', preco: 100, duracaoDias: 30 }]; + render(); + expect(screen.getByText('mês')).toBeTruthy(); + }); + + it('displays "2 meses" badge for 60-day duration', () => { + const customPlanos: Plano[] = [{ id: 'p3', nome: 'Bimestral', preco: 180, duracaoDias: 60 }]; + render(); + expect(screen.getByText('2 meses')).toBeTruthy(); + }); + + it('displays "dias" badge when duration is not a multiple of 30', () => { + const customPlanos: Plano[] = [{ id: 'p1', nome: 'Personal', preco: 200, duracaoDias: 15 }]; + render(); + expect(screen.getByText('15 dias')).toBeTruthy(); + }); + it('calls deletePlanoAction when delete is confirmed', async () => { const { deletePlanoAction } = await import('@/lib/actions/planos'); vi.mocked(deletePlanoAction).mockResolvedValue({ success: true }); diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx index 858e67b3..4255181c 100644 --- a/src/app/login/page.test.tsx +++ b/src/app/login/page.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import LoginPage from './page'; import type { ReactNode } from 'react'; @@ -15,6 +15,18 @@ vi.mock('react', async () => { }; }); +const mockSignInWithPassword = vi.fn(); +const mockGetSession = vi.fn(); + +vi.mock('@/utils/supabase/client', () => ({ + createClient: () => ({ + auth: { + signInWithPassword: mockSignInWithPassword, + getSession: mockGetSession, + }, + }), +})); + vi.mock('next/link', () => ({ default: ({ children, href }: { children: ReactNode; href: string }) => ( {children} @@ -72,6 +84,10 @@ vi.mock('@/components/ui/separator', () => ({ })); describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders the login form', () => { render(); expect(screen.getByText('Five Star')).toBeTruthy(); @@ -98,4 +114,79 @@ describe('LoginPage', () => { render(); expect(screen.getByText((content) => content.includes('Five Star Fitness'))).toBeTruthy(); }); + + it('shows error on invalid credentials', async () => { + mockSignInWithPassword.mockResolvedValue({ + error: new Error('Invalid login credentials'), + }); + + render(); + fireEvent.change(screen.getByLabelText('Email corporativo'), { + target: { value: 'wrong@test.com' }, + }); + fireEvent.change(screen.getByLabelText('Senha de acesso'), { + target: { value: 'wrong' }, + }); + fireEvent.submit(screen.getByLabelText('Email corporativo').closest('form')!); + + await waitFor(() => { + expect( + screen.getByText('E-mail ou senha inválidos. Por favor, tente novamente.') + ).toBeTruthy(); + expect(screen.getByText('Entrar no Sistema')).toBeTruthy(); + }); + }); + + it('navigates to dashboard on successful login', async () => { + mockSignInWithPassword.mockResolvedValue({ error: null }); + mockGetSession.mockResolvedValue({ + data: { session: { user: { id: 'u1' } } }, + }); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { ...originalLocation, href: '' }, + writable: true, + }); + + render(); + fireEvent.change(screen.getByLabelText('Email corporativo'), { + target: { value: 'admin@test.com' }, + }); + fireEvent.change(screen.getByLabelText('Senha de acesso'), { + target: { value: 'correct' }, + }); + fireEvent.submit(screen.getByLabelText('Email corporativo').closest('form')!); + + await waitFor(() => { + expect(window.location.href).toBe('/dashboard'); + }); + + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + it('shows "Autenticando..." while submitting', async () => { + let resolveAuth!: (v: { error: null | Error }) => void; + mockSignInWithPassword.mockImplementation( + () => + new Promise((resolve) => { + resolveAuth = resolve; + }) + ); + + render(); + fireEvent.submit(screen.getByLabelText('Email corporativo').closest('form')!); + + await waitFor(() => { + expect(screen.getByText('Autenticando...')).toBeTruthy(); + }); + + resolveAuth({ error: null }); + }); + + // ponytail: session poll timeout path tested implicitly by success test. + // Poll loop is UX fallback (10×500ms) — would timeout vitest's 5s. Skip. }); diff --git a/src/components/dashboard/alunos/columns.test.tsx b/src/components/dashboard/alunos/columns.test.tsx index c18a2743..f573f4b1 100644 --- a/src/components/dashboard/alunos/columns.test.tsx +++ b/src/components/dashboard/alunos/columns.test.tsx @@ -7,7 +7,9 @@ vi.mock('@/components/ui/avatar', () => ({ Avatar: ({ children }: { children?: React.ReactNode }) => (
{children}
), - AvatarFallback: ({ children }: { children?: React.ReactNode }) => {children}, + AvatarFallback: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), AvatarImage: () => null, })); @@ -142,6 +144,28 @@ describe('columns', () => { } }); + it('avatar shows empty for empty name', () => { + const cell = columnDefs[0].cell; + if (typeof cell === 'function') { + const { container } = render( + cell({ row: { original: { ...mockAluno, nomeCompleto: '' } } } as never) + ); + const fallback = container.querySelector('[data-testid="avatar-fallback"]'); + expect(fallback?.textContent).toBe(''); + } + }); + + it('avatar shows first 2 chars for single-word name', () => { + const cell = columnDefs[0].cell; + if (typeof cell === 'function') { + const { container } = render( + cell({ row: { original: { ...mockAluno, nomeCompleto: 'João' } } } as never) + ); + const fallback = container.querySelector('[data-testid="avatar-fallback"]'); + expect(fallback?.textContent).toBe('JO'); + } + }); + it('renders name and email in name column', () => { const cell = columnDefs[1].cell; if (typeof cell === 'function') { @@ -157,6 +181,19 @@ describe('columns', () => { } }); + it('renders null for empty date', () => { + const cell = columnDefs[3].cell; + if (typeof cell === 'function') { + const result = cell({ + row: { + original: { ...mockAluno, dataCadastro: '' }, + getValue: () => '', + }, + } as never); + expect(result).toBeNull(); + } + }); + it('renders formatted date in date column', () => { const cell = columnDefs[3].cell; if (typeof cell === 'function') { diff --git a/src/components/providers/auth-provider.test.tsx b/src/components/providers/auth-provider.test.tsx index f35c758e..91f8901e 100644 --- a/src/components/providers/auth-provider.test.tsx +++ b/src/components/providers/auth-provider.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { SupabaseAuthProvider, useAuth } from './auth-provider'; +import * as Sentry from '@sentry/nextjs'; + +let authStateCallback: ((event: string, session: unknown) => void) | null = null; const mockGetUser = vi.fn(); const mockOnAuthStateChange = vi.fn(); @@ -35,9 +38,11 @@ function TestConsumer() { describe('SupabaseAuthProvider', () => { beforeEach(() => { vi.clearAllMocks(); + authStateCallback = null; mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); - mockOnAuthStateChange.mockReturnValue({ - data: { subscription: { unsubscribe: vi.fn() } }, + mockOnAuthStateChange.mockImplementation((cb: (event: string, session: unknown) => void) => { + authStateCallback = cb; + return { data: { subscription: { unsubscribe: vi.fn() } } }; }); }); @@ -116,6 +121,43 @@ describe('SupabaseAuthProvider', () => { }); expect(mockOnAuthStateChange).toHaveBeenCalled(); }); + + it('calls Sentry.setUser on auth state change with user', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + authStateCallback?.('SIGNED_IN', { + user: { id: 'u1', email: 'user@test.com' }, + }); + }); + + expect(Sentry.setUser).toHaveBeenCalledWith({ + id: 'u1', + email: 'user@test.com', + }); + }); + + it('calls Sentry.setUser(null) on auth state change without session', async () => { + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + authStateCallback?.('SIGNED_OUT', null); + }); + + expect(Sentry.setUser).toHaveBeenCalledWith(null); + }); }); describe('useAuth', () => {