Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-06-13 17:32:41 -03:00
commit 759e2663ec
311 changed files with 31868 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { useAuth } from '../../modules/auth';
import { Button } from '../../components/ui';
import { useToast } from '../../components/ui/Toast';
import { authService } from '../../modules/auth/auth.service';
import { PasswordStrengthIndicator } from '../../components/shared/PasswordStrengthIndicator';
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Senha atual é obrigatória'),
newPassword: z
.string()
.min(8, 'A senha deve ter no mínimo 8 caracteres')
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
export function ChangePasswordPage() {
const { user, updateUser } = useAuth();
const navigate = useNavigate();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const isFirstAccess = user?.mustChangePassword === true;
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
const newPasswordValue = watch('newPassword', '');
async function onSubmit(data: ChangePasswordFormData) {
setApiError(null);
try {
await authService.changePassword({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
updateUser({ mustChangePassword: false });
showToast('Senha alterada com sucesso', 'success');
navigate('/', { replace: true });
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 401) {
setApiError('Senha atual incorreta');
} else {
setApiError('Erro ao alterar senha. Tente novamente.');
}
} else {
setApiError('Erro ao alterar senha. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">
{isFirstAccess ? 'Primeiro acesso — defina sua senha' : 'Trocar senha'}
</p>
</div>
<p className="mb-6 text-small text-text-secondary">
{isFirstAccess
? 'Bem-vindo! Para continuar, você precisa definir uma nova senha.'
: 'Informe sua senha atual e escolha uma nova senha.'}
</p>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="currentPassword" className="mb-1 block text-small text-text-secondary">
{isFirstAccess ? 'Senha temporária' : 'Senha atual'}
</label>
<input
id="currentPassword"
type="password"
placeholder="••••••••"
{...register('currentPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.currentPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.currentPassword.message}
</p>
)}
</div>
<div>
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
Nova senha
</label>
<input
id="newPassword"
type="password"
placeholder="••••••••"
{...register('newPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.newPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.newPassword.message}
</p>
)}
<PasswordStrengthIndicator password={newPasswordValue} />
</div>
<div>
<label htmlFor="confirmPassword" className="mb-1 block text-small text-text-secondary">
Confirmar nova senha
</label>
<input
id="confirmPassword"
type="password"
placeholder="••••••••"
{...register('confirmPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.confirmPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
{apiError && (
<p className="text-small text-red-400" role="alert">
{apiError}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Alterando...' : 'Alterar senha'}
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { authService } from '../../modules/auth/auth.service';
import { Button } from '../../components/ui';
const forgotPasswordSchema = z.object({
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
});
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
export function ForgotPasswordPage() {
const [submitted, setSubmitted] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
async function onSubmit(data: ForgotPasswordFormData) {
setApiError(null);
try {
await authService.forgotPassword(data.email);
setSubmitted(true);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError('Erro ao processar solicitação. Tente novamente.');
} else {
setApiError('Erro ao processar solicitação. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
{submitted ? (
<div role="alert" className="text-center">
<p className="mb-4 text-body text-text-primary">
Se o e-mail informado estiver cadastrado, você receberá um link de redefinição em
breve.
</p>
<Link to="/login" className="text-small text-isis-blue hover:underline">
Voltar para o login
</Link>
</div>
) : (
<>
<h1 className="mb-2 text-center text-body font-semibold text-text-primary">
Recuperar acesso
</h1>
<p className="mb-6 text-small text-text-secondary">
Informe seu e-mail para receber um link de redefinição de senha.
</p>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
E-mail
</label>
<input
id="email"
type="email"
placeholder="seu@email.com"
{...register('email')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.email && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.email.message}
</p>
)}
</div>
{apiError && (
<p className="text-small text-red-400" role="alert">
{apiError}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Enviando...' : 'Enviar link'}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-small text-isis-blue hover:underline">
Voltar para o login
</Link>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { useAuth } from '../../modules/auth';
import { Button } from '../../components/ui';
const loginSchema = z.object({
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
password: z.string().min(1, 'Senha é obrigatória'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
setApiError(null);
try {
await login(data.email, data.password);
navigate('/', { replace: true });
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 401) {
setApiError('E-mail ou senha incorretos');
} else if (status === 403) {
setApiError('Usuário inativo');
} else {
setApiError('Erro ao realizar login. Tente novamente.');
}
} else {
setApiError('Erro ao realizar login. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
E-mail
</label>
<input
id="email"
type="email"
placeholder="seu@email.com"
{...register('email')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.email && <p className="mt-1 text-small text-red-400">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="mb-1 block text-small text-text-secondary">
Senha
</label>
<input
id="password"
type="password"
placeholder="••••••••"
{...register('password')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.password && (
<p className="mt-1 text-small text-red-400">{errors.password.message}</p>
)}
</div>
{apiError && <p className="text-small text-red-400">{apiError}</p>}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/esqueci-senha" className="text-small text-isis-blue hover:underline">
Esqueceu sua senha?
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { authService } from '../../modules/auth/auth.service';
import { Button } from '../../components/ui';
const passwordSchema = z
.object({
newPassword: z
.string()
.min(8, 'A senha deve ter no mínimo 8 caracteres')
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type ResetPasswordFormData = z.infer<typeof passwordSchema>;
export function ResetPasswordPage() {
const { token } = useParams<{ token: string }>();
const [success, setSuccess] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [isExpired, setIsExpired] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(passwordSchema),
});
async function onSubmit(data: ResetPasswordFormData) {
setApiError(null);
setIsExpired(false);
try {
await authService.resetPassword(token!, {
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
});
setSuccess(true);
} catch (err) {
if (axios.isAxiosError(err)) {
const message: string = err.response?.data?.message ?? '';
if (message.toLowerCase().includes('expirado')) {
setIsExpired(true);
setApiError('O link de redefinição expirou.');
} else if (message) {
setApiError(message);
} else {
setApiError('Erro ao redefinir senha. Tente novamente.');
}
} else {
setApiError('Erro ao redefinir senha. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
{success ? (
<div role="alert" className="text-center">
<p className="mb-4 text-body text-text-primary">
Senha redefinida com sucesso! Você pode fazer login.
</p>
<Link to="/login" className="text-small text-isis-blue hover:underline">
Ir para o login
</Link>
</div>
) : (
<>
<h1 className="mb-6 text-center text-body font-semibold text-text-primary">
Redefinir senha
</h1>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
Nova senha
</label>
<input
id="newPassword"
type="password"
placeholder="••••••••"
{...register('newPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.newPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.newPassword.message}
</p>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="mb-1 block text-small text-text-secondary"
>
Confirmar nova senha
</label>
<input
id="confirmPassword"
type="password"
placeholder="••••••••"
{...register('confirmPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.confirmPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
{apiError && (
<div role="alert">
<p className="text-small text-red-400">{apiError}</p>
{isExpired && (
<Link
to="/esqueci-senha"
className="mt-1 block text-small text-isis-blue hover:underline"
>
Solicitar novo link
</Link>
)}
</div>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Redefinindo...' : 'Redefinir senha'}
</Button>
</form>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ChangePasswordPage } from '../ChangePasswordPage';
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
changePassword: vi.fn(),
},
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockChangePassword = vi.mocked(authService.changePassword);
function renderPage(userOverrides: Partial<AuthContextData['user']> = {}) {
const updateUser = vi.fn();
const user: AuthContextData['user'] = {
id: '1',
name: 'Test',
email: 'test@test.com',
role: 'ADMIN',
mustChangePassword: false,
...userOverrides,
};
const authValue: AuthContextData = {
user,
token: 'token',
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateUser,
};
return {
updateUser,
...render(
<MemoryRouter>
<AuthContext.Provider value={authValue}>
<ChangePasswordPage />
</AuthContext.Provider>
</MemoryRouter>,
),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockReset();
});
describe('ChangePasswordPage', () => {
it('shows "Primeiro acesso" context when mustChangePassword=true', () => {
renderPage({ mustChangePassword: true });
expect(screen.getByText('Primeiro acesso — defina sua senha')).toBeInTheDocument();
expect(screen.getByText(/bem-vindo/i)).toBeInTheDocument();
});
it('shows "Trocar senha" context when mustChangePassword=false', () => {
renderPage({ mustChangePassword: false });
expect(screen.getByText('Trocar senha')).toBeInTheDocument();
});
it('shows password complexity error for short password', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Senha atual'), 'current');
await user.type(screen.getByLabelText('Nova senha'), 'short');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
});
});
it('shows complexity error for missing uppercase', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Senha atual'), 'current');
await user.type(screen.getByLabelText('Nova senha'), 'nouppercase1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'nouppercase1');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
).toBeInTheDocument();
});
});
it('renders PasswordStrengthIndicator', () => {
renderPage();
expect(screen.getByRole('progressbar', { name: 'Força da senha' })).toBeInTheDocument();
});
it('updates PasswordStrengthIndicator as user types', async () => {
const user = userEvent.setup();
renderPage();
const newPasswordInput = screen.getByLabelText('Nova senha');
const progressBar = screen.getByRole('progressbar', { name: 'Força da senha' });
expect(progressBar).toHaveAttribute('aria-valuenow', '0');
await user.type(newPasswordInput, 'ValidPass1');
await waitFor(() => {
expect(progressBar).toHaveAttribute('aria-valuenow', '4');
});
});
it('calls updateUser with mustChangePassword=false on success', async () => {
const user = userEvent.setup();
mockChangePassword.mockResolvedValue({ message: 'ok' });
const { updateUser } = renderPage({ mustChangePassword: true });
await user.type(screen.getByLabelText('Senha temporária'), 'temp123');
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(updateUser).toHaveBeenCalledWith({ mustChangePassword: false });
});
});
});

View File

@@ -0,0 +1,89 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ForgotPasswordPage } from '../ForgotPasswordPage';
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
forgotPassword: vi.fn(),
},
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockForgotPassword = vi.mocked(authService.forgotPassword);
function renderPage() {
return render(
<MemoryRouter>
<ForgotPasswordPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ForgotPasswordPage', () => {
it('renders email input and submit button', () => {
renderPage();
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Enviar link' })).toBeInTheDocument();
});
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'invalido');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
mockForgotPassword.mockImplementation(() => new Promise(() => {}));
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Enviando...' })).toBeDisabled();
});
});
it('hides form and shows confirmation message on success', async () => {
const user = userEvent.setup();
mockForgotPassword.mockResolvedValue({ message: 'link enviado' });
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.queryByLabelText('E-mail')).not.toBeInTheDocument();
expect(
screen.getByText(/se o e-mail informado estiver cadastrado/i),
).toBeInTheDocument();
});
});
it('shows error message on network failure', async () => {
const user = userEvent.setup();
mockForgotPassword.mockRejectedValue(new Error('Network error'));
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByText('Erro ao processar solicitação. Tente novamente.')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { LoginPage } from '../LoginPage';
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
function renderLoginPage(authOverrides: Partial<AuthContextData> = {}) {
const authValue: AuthContextData = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateUser: vi.fn(),
...authOverrides,
};
return {
authValue,
...render(
<MemoryRouter>
<AuthContext.Provider value={authValue}>
<LoginPage />
</AuthContext.Provider>
</MemoryRouter>,
),
};
}
beforeEach(() => {
mockNavigate.mockReset();
});
describe('LoginPage', () => {
it('renders form with email, password and submit button', () => {
renderLoginPage();
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
expect(screen.getByLabelText('Senha')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Entrar' })).toBeInTheDocument();
});
it('shows validation errors when submitting empty form', async () => {
const user = userEvent.setup();
renderLoginPage();
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail é obrigatório')).toBeInTheDocument();
});
expect(screen.getByText('Senha é obrigatória')).toBeInTheDocument();
});
it('shows email validation error for invalid email', async () => {
const user = userEvent.setup();
renderLoginPage();
// jsdom doesn't enforce type="email" validation, so RHF + Zod validates
await user.type(screen.getByLabelText('E-mail'), 'invalido');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
const loginMock = vi.fn(() => new Promise<void>(() => {})); // never resolves
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Entrando...' })).toBeDisabled();
});
});
it('shows API error message on 401', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 401 },
};
const loginMock = vi.fn().mockRejectedValue(axiosError);
// Mock axios.isAxiosError to recognize our error
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail ou senha incorretos')).toBeInTheDocument();
});
});
it('shows API error message on 403 (inactive user)', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 403 },
};
const loginMock = vi.fn().mockRejectedValue(axiosError);
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('Usuário inativo')).toBeInTheDocument();
});
});
it('navigates to / on successful login', async () => {
const user = userEvent.setup();
const loginMock = vi.fn().mockResolvedValue(undefined);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(loginMock).toHaveBeenCalledWith('admin@iasis.com.br', 'password');
});
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
it('stores token in localStorage after successful login', async () => {
const user = userEvent.setup();
const loginMock = vi.fn().mockImplementation(async () => {
// Simulate what AuthProvider.login does
localStorage.setItem('@iasis:token', 'test-token');
});
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(localStorage.getItem('@iasis:token')).toBe('test-token');
});
});
});

View File

@@ -0,0 +1,103 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ResetPasswordPage } from '../ResetPasswordPage';
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
resetPassword: vi.fn(),
},
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockResetPassword = vi.mocked(authService.resetPassword);
function renderPage(token = 'test-token') {
return render(
<MemoryRouter initialEntries={[`/redefinir-senha/${token}`]}>
<Routes>
<Route path="/redefinir-senha/:token" element={<ResetPasswordPage />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ResetPasswordPage', () => {
it('renders password fields and submit button', () => {
renderPage();
expect(screen.getByLabelText('Nova senha')).toBeInTheDocument();
expect(screen.getByLabelText('Confirmar nova senha')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Redefinir senha' })).toBeInTheDocument();
});
it('shows complexity error when password is too short', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'short');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
});
});
it('shows uppercase error when password has no uppercase letter', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'validpass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'validpass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
).toBeInTheDocument();
});
});
it('shows success message on 200 response', async () => {
const user = userEvent.setup();
mockResetPassword.mockResolvedValue({ message: 'Senha redefinida com sucesso.' });
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText(/senha redefinida com sucesso/i)).toBeInTheDocument();
});
});
it('shows expired link message and link to /esqueci-senha on 400 with expired message', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 400, data: { message: 'Token de redefinição expirado' } },
};
mockResetPassword.mockRejectedValue(axiosError);
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText('O link de redefinição expirou.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Solicitar novo link' })).toBeInTheDocument();
});
});
});