diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx new file mode 100644 index 0000000..2fb345e --- /dev/null +++ b/src/web/src/tests/features/auth/LoginPage.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { LoginPage } from '../../../features/auth/pages/LoginPage' +import { useAuthStore } from '../../../stores/authStore' + +// Must be at top level for Vitest hoisting +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const API_URL = 'http://localhost:5000' + +const mockLoginResponse = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + refreshToken: 'refresh-token-abc', + expiresIn: 3600, + usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, +} + +const server = setupServer( + http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => { + const body = await request.json() as { username: string; password: string } + if (body.username === 'admin' && body.password === '@Diego550@') { + return HttpResponse.json(mockLoginResponse, { status: 200 }) + } + return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 }) + }), +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().logout() + localStorage.clear() + mockNavigate.mockClear() +}) +afterAll(() => server.close()) + +function renderLoginPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + + return render( + + + + + , + ) +} + +describe('LoginPage', () => { + it('renders username and password inputs and submit button', () => { + renderLoginPage() + + expect(screen.getByLabelText(/usuario/i)).toBeInTheDocument() + expect(screen.getByLabelText(/contraseña/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /ingresar/i })).toBeInTheDocument() + }) + + it('shows error message on 401 invalid credentials', async () => { + const user = userEvent.setup() + renderLoginPage() + + await user.type(screen.getByLabelText(/usuario/i), 'admin') + await user.type(screen.getByLabelText(/contraseña/i), 'wrongpassword') + await user.click(screen.getByRole('button', { name: /ingresar/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/credenciales/i) + }) + }) + + it('disables submit button while loading', async () => { + const user = userEvent.setup() + renderLoginPage() + + server.use( + http.post(`${API_URL}/api/v1/auth/login`, async () => { + await new Promise((resolve) => setTimeout(resolve, 300)) + return HttpResponse.json(mockLoginResponse) + }), + ) + + await user.type(screen.getByLabelText(/usuario/i), 'admin') + await user.type(screen.getByLabelText(/contraseña/i), '@Diego550@') + + const button = screen.getByRole('button', { name: /ingresar/i }) + await user.click(button) + + // Button should be disabled during the pending request + expect(button).toBeDisabled() + }) + + it('saves auth to store on successful login', async () => { + const user = userEvent.setup() + renderLoginPage() + + await user.type(screen.getByLabelText(/usuario/i), 'admin') + await user.type(screen.getByLabelText(/contraseña/i), '@Diego550@') + await user.click(screen.getByRole('button', { name: /ingresar/i })) + + await waitFor(() => { + const state = useAuthStore.getState() + expect(state.accessToken).toBe(mockLoginResponse.accessToken) + expect(state.user?.username).toBe('admin') + }) + }) +}) diff --git a/src/web/src/tests/features/auth/authApi.test.ts b/src/web/src/tests/features/auth/authApi.test.ts new file mode 100644 index 0000000..cc650c9 --- /dev/null +++ b/src/web/src/tests/features/auth/authApi.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { login } from '../../../features/auth/api/authApi' + +const API_URL = 'http://localhost:5000' + +const mockLoginResponse = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.signature', + refreshToken: 'opaque-refresh-token-abc123', + expiresIn: 3600, + usuario: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + }, +} + +const server = setupServer( + http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => { + const body = await request.json() as { username: string; password: string } + if (body.username === 'admin' && body.password === '@Diego550@') { + return HttpResponse.json(mockLoginResponse, { status: 200 }) + } + return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 }) + }), +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('login()', () => { + it('returns auth data on valid credentials', async () => { + const result = await login('admin', '@Diego550@') + + expect(result.accessToken).toBe(mockLoginResponse.accessToken) + expect(result.refreshToken).toBe(mockLoginResponse.refreshToken) + expect(result.expiresIn).toBe(3600) + expect(result.usuario.username).toBe('admin') + }) + + it('throws on invalid credentials (401)', async () => { + await expect(login('admin', 'wrongpassword')).rejects.toThrow() + }) +}) diff --git a/src/web/src/tests/setup.ts b/src/web/src/tests/setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/web/src/tests/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts new file mode 100644 index 0000000..995c458 --- /dev/null +++ b/src/web/src/tests/stores/authStore.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useAuthStore } from '../../stores/authStore' + +describe('authStore', () => { + beforeEach(() => { + // Reset store state before each test + useAuthStore.getState().logout() + localStorage.clear() + }) + + describe('initial state', () => { + it('starts with null user and null accessToken', () => { + const state = useAuthStore.getState() + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + }) + + describe('setAuth', () => { + it('stores user and accessToken in state', () => { + const payload = { + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', + refreshToken: 'opaque-refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user).toEqual(payload.user) + expect(state.accessToken).toBe(payload.accessToken) + }) + + it('persists auth data to localStorage under auth-storage key', () => { + const payload = { + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', + refreshToken: 'opaque-refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const stored = localStorage.getItem('auth-storage') + expect(stored).not.toBeNull() + const parsed = JSON.parse(stored!) + expect(parsed.state.accessToken).toBe(payload.accessToken) + expect(parsed.state.user.username).toBe('admin') + }) + }) + + describe('logout', () => { + it('clears user and accessToken from state', () => { + // Setup: set auth first + useAuthStore.getState().setAuth({ + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + accessToken: 'some-token', + refreshToken: 'some-refresh', + expiresIn: 3600, + }) + + useAuthStore.getState().logout() + + const state = useAuthStore.getState() + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + + it('removes auth-storage from localStorage on logout', () => { + useAuthStore.getState().setAuth({ + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + accessToken: 'some-token', + refreshToken: 'some-refresh', + expiresIn: 3600, + }) + + useAuthStore.getState().logout() + + const stored = localStorage.getItem('auth-storage') + // After logout the persisted state should have null user/token + if (stored !== null) { + const parsed = JSON.parse(stored) + expect(parsed.state.user).toBeNull() + expect(parsed.state.accessToken).toBeNull() + } + }) + }) +})