diff --git a/backend/src/modules/types/credentials.types.ts b/backend/src/modules/types/credentials.types.ts index 1ba8f15..21f235d 100644 --- a/backend/src/modules/types/credentials.types.ts +++ b/backend/src/modules/types/credentials.types.ts @@ -1,11 +1,35 @@ import z from "zod"; export const RegisterSchema = z.object({ - email: z.string().email("Email inválido"), - password: z.string().min(8, "Senha deve ter no mínimo 8 caracteres"), - name: z.string().min(1, "Nome é obrigatório").optional(), - phone: z.string().optional(), - cpf: z.string().optional(), + email: z + .string() + .email("Email inválido") + .max(254, "Email deve ter no máximo 254 caracteres"), + password: z + .string() + .min(8, "Senha deve ter no mínimo 8 caracteres") + .max(128, "Senha deve ter no máximo 128 caracteres"), + name: z + .string() + .min(1, "Nome é obrigatório") + .max(100, "Nome deve ter no máximo 100 caracteres") + .optional(), + phone: z + .string() + .max(16, "Telefone deve ter no máximo 15 dígitos") + .refine( + (value) => value.replace(/\D/g, "").length <= 15, + "Telefone deve ter no máximo 15 dígitos", + ) + .optional(), + cpf: z + .string() + .max(14, "CPF deve ter no máximo 11 dígitos") + .refine( + (value) => value.replace(/\D/g, "").length <= 11, + "CPF deve ter no máximo 11 dígitos", + ) + .optional(), technologies: z.array(z.string()).optional(), level: z.string().optional(), }); diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 988f0d2..49d7b23 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -101,17 +101,19 @@ describe("CredentialsService", () => { }; }); mocks.insertReturning.mockResolvedValue([mockUser]); - mocks.transaction.mockImplementation(async (callback) => callback({ - insert: vi.fn(() => ({ - values: mocks.insertValues.mockImplementation(() => ({ - returning: mocks.insertReturning, + mocks.transaction.mockImplementation(async (callback) => + callback({ + insert: vi.fn(() => ({ + values: mocks.insertValues.mockImplementation(() => ({ + returning: mocks.insertReturning, + })), })), - })), - query: { - credentials: { findFirst: mocks.credentialsFindFirst }, - users: { findFirst: mocks.usersFindFirst }, - }, - })); + query: { + credentials: { findFirst: mocks.credentialsFindFirst }, + users: { findFirst: mocks.usersFindFirst }, + }, + }), + ); // Define retornos seguros e limpos para evitar falhas colaterais mocks.credentialsFindFirst.mockResolvedValue(null); @@ -178,6 +180,18 @@ describe("CredentialsService", () => { ).rejects.toThrow(); }); + it.each([ + ["nome", { name: "a".repeat(101) }], + ["email", { email: `${"a".repeat(250)}@x.com` }], + ["telefone", { phone: "1".repeat(16) }], + ["senha", { password: "a".repeat(129) }], + ["CPF", { cpf: "1".repeat(12) }], + ])("rejeita %s acima do limite permitido", async (_field, override) => { + await expect( + service.register({ ...registerInput, ...override }), + ).rejects.toThrow(); + }); + it("session contém userId e role", async () => { const { session } = await service.register(registerInput); diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml index baa9a89..becef28 100644 --- a/docker-compose.infra.yml +++ b/docker-compose.infra.yml @@ -8,8 +8,6 @@ services: POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" networks: - vagas-net restart: unless-stopped @@ -24,8 +22,6 @@ services: container_name: vagas-valkey volumes: - valkey_data:/data - ports: - - "6379:6379" networks: - vagas-net restart: unless-stopped diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index f60d67b..9a7e28d 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -34,7 +34,14 @@ export function AppRoutes() { return ( - } /> + + + + } + /> diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index b03da44..cfc836c 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -30,6 +30,14 @@ const LEVEL_OPTIONS = [ { value: "senior", label: "Sênior" }, ]; +const REGISTER_LIMITS = { + name: 100, + email: 254, + phoneDigits: 15, + password: 128, + cpf: 14, +} as const; + function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -163,8 +171,8 @@ export default function RegisterSide() { if (!password) { setPasswordError("O campo de senha é obrigatório."); isValid = false; - } else if (password.length < 6) { - setPasswordError("A senha precisa conter pelo menos 6 caracteres."); + } else if (password.length < 8) { + setPasswordError("A senha precisa conter pelo menos 8 caracteres."); isValid = false; } @@ -196,7 +204,9 @@ export default function RegisterSide() { window.location.href = "/login?registered=true"; } catch (error: unknown) { console.error("Erro no cadastro:", error); - setApiError(getErrorMessage(error, "Erro ao cadastrar. Tente novamente.")); + setApiError( + getErrorMessage(error, "Erro ao cadastrar. Tente novamente."), + ); } finally { setIsLoading(false); } @@ -260,6 +270,7 @@ export default function RegisterSide() { type="text" value={nome} onChange={(e) => setNome(e.target.value)} + maxLength={REGISTER_LIMITS.name} placeholder="benevanio" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${nomeError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -282,6 +293,7 @@ export default function RegisterSide() { type="email" value={email} onChange={(e) => setEmail(e.target.value)} + maxLength={REGISTER_LIMITS.email} placeholder="benevanio@dev.com.br" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${emailError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -303,7 +315,15 @@ export default function RegisterSide() { international defaultCountry="BR" value={telefone} - onChange={setTelefone} + onChange={(value) => { + if ( + !value || + value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits + ) { + setTelefone(value); + } + }} + numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }} disabled={isLoading} className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom" placeholder="(34) 23456-7890" @@ -328,6 +348,7 @@ export default function RegisterSide() { type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} + maxLength={REGISTER_LIMITS.password} placeholder="Ex: ••••••••••••" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${passwordError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} @@ -366,6 +387,7 @@ export default function RegisterSide() { type="text" value={cpf} onChange={(e) => setCpf(formatCpf(e.target.value))} + maxLength={REGISTER_LIMITS.cpf} placeholder="091.000.000-00" disabled={isLoading} className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${cpfError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`} diff --git a/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx b/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx index bbaa0c3..6b781e3 100644 --- a/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx +++ b/frontend/src/domains/jobs/presentation/components/JobsFiltersCard.tsx @@ -115,6 +115,7 @@ export function JobsFiltersCard({ setSearchTerm(event.target.value)} + maxLength={100} className="h-14 w-full rounded-2xl border border-slate-300 bg-white pl-11 pr-14 text-base text-slate-900 placeholder:text-slate-500 focus-visible:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#091224] dark:text-slate-100 dark:placeholder:text-slate-400" placeholder="Buscar por título, empresa, local ou link" /> @@ -183,11 +184,12 @@ export function JobsFiltersCard({ selectedFilters.map((filter) => ( - {filter} + {filter} , + button: ({ children, ...props }: any) => ( + + ), }, })); vi.mock("react-phone-number-input", () => ({ - default: ({ value, onChange, placeholder, disabled }: any) => ( + default: ({ + value, + onChange, + placeholder, + disabled, + numberInputProps, + }: any) => ( onChange(e.target.value)} placeholder={placeholder} disabled={disabled} + {...numberInputProps} /> ), })); @@ -94,24 +103,71 @@ describe("RegisterSide", () => { expect(screen.getByLabelText(/senha/i)).toHaveAttribute("type", "text"); }); + it("limita os campos de cadastro conforme as regras da API", () => { + render(); + + expect(screen.getByLabelText(/nome/i)).toHaveAttribute("maxlength", "100"); + expect(screen.getByLabelText(/email/i)).toHaveAttribute("maxlength", "254"); + expect(screen.getByPlaceholderText(/\(34\)/i)).toHaveAttribute( + "maxlength", + "16", + ); + expect(screen.getByLabelText(/senha/i)).toHaveAttribute("maxlength", "128"); + expect(screen.getByLabelText(/cpf/i)).toHaveAttribute("maxlength", "14"); + }); + + it("exige senha com pelo menos oito caracteres", async () => { + render(); + fillRequiredRegisterFields(); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "1234567" }, + }); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + expect( + await screen.findByText(/pelo menos 8 caracteres/i), + ).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + it("mostra erros obrigatórios ao submeter vazio", async () => { render(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); - expect(await screen.findByText(/campo de nome é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de e-mail é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de telefone é obrigatório/i)).toBeInTheDocument(); - expect(await screen.findByText(/campo de senha é obrigatório/i)).toBeInTheDocument(); + expect( + await screen.findByText(/campo de nome é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de e-mail é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de telefone é obrigatório/i), + ).toBeInTheDocument(); + expect( + await screen.findByText(/campo de senha é obrigatório/i), + ).toBeInTheDocument(); expect(await screen.findByText(/selecione seu nível/i)).toBeInTheDocument(); }); it("valida CPF inválido quando preenchido", async () => { render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Usuário" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "teste@email.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); - fireEvent.change(screen.getByLabelText(/nível de experiência/i), { target: { value: "pleno" } }); - fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "123" } }); + fireEvent.change(screen.getByLabelText(/nome/i), { + target: { value: "Usuário" }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "teste@email.com" }, + }); + fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { + target: { value: "+5534999999999" }, + }); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "12345678" }, + }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { + target: { value: "pleno" }, + }); + fireEvent.change(screen.getByLabelText(/cpf/i), { + target: { value: "123" }, + }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); expect(await screen.findByText(/cpf inválido/i)).toBeInTheDocument(); }); @@ -124,7 +180,7 @@ describe("RegisterSide", () => { await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", - password: "123456", + password: "12345678", name: "Bene", phone: "+5534999999999", cpf: undefined, @@ -138,12 +194,14 @@ describe("RegisterSide", () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); fillRequiredRegisterFields(); - fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "12345678901" } }); + fireEvent.change(screen.getByLabelText(/cpf/i), { + target: { value: "12345678901" }, + }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", - password: "123456", + password: "12345678", name: "Bene", phone: "+5534999999999", cpf: "123.456.789-01", @@ -165,7 +223,9 @@ describe("RegisterSide", () => { render(); fillRequiredRegisterFields(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); - expect(await screen.findByRole("button", { name: /cadastrando\.\.\./i })).toBeDisabled(); + expect( + await screen.findByRole("button", { name: /cadastrando\.\.\./i }), + ).toBeDisabled(); }); it("formata CPF corretamente", () => { @@ -188,7 +248,7 @@ describe("RegisterSide", () => { fireEvent.change(nomeInput, { target: { value: "Bene" } }); fireEvent.change(emailInput, { target: { value: "bene@teste.com" } }); fireEvent.change(telefoneInput, { target: { value: "+5534999999999" } }); - fireEvent.change(passwordInput, { target: { value: "123456" } }); + fireEvent.change(passwordInput, { target: { value: "12345678" } }); fireEvent.change(levelInput, { target: { value: "pleno" } }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { @@ -202,30 +262,36 @@ describe("RegisterSide", () => { it("redireciona para GitHub OAuth ao clicar no botao GitHub", async () => { mockGetGithubAuthUrl.mockResolvedValueOnce( - "https://github.com/login/oauth/authorize?state=abc" + "https://github.com/login/oauth/authorize?state=abc", ); render(); const buttons = screen.getAllByRole("button"); - const githubButton = buttons.find(btn => btn.querySelector('svg.fill-gray-900')); + const githubButton = buttons.find((btn) => + btn.querySelector("svg.fill-gray-900"), + ); fireEvent.click(githubButton!); await waitFor(() => { expect(mockGetGithubAuthUrl).toHaveBeenCalled(); expect(window.location.href).toBe( - "https://github.com/login/oauth/authorize?state=abc" + "https://github.com/login/oauth/authorize?state=abc", ); }); }); it("exibe erro quando GitHub OAuth falha", async () => { - mockGetGithubAuthUrl.mockRejectedValueOnce(new Error("Github indisponível")); + mockGetGithubAuthUrl.mockRejectedValueOnce( + new Error("Github indisponível"), + ); render(); const buttons = screen.getAllByRole("button"); - const githubButton = buttons.find(btn => btn.querySelector('svg.fill-gray-900')); + const githubButton = buttons.find((btn) => + btn.querySelector("svg.fill-gray-900"), + ); fireEvent.click(githubButton!); expect(await screen.findByText(/Github indisponível/i)).toBeInTheDocument(); @@ -233,7 +299,7 @@ describe("RegisterSide", () => { it("redireciona para LinkedIn OAuth ao clicar no botao LinkedIn", async () => { mockGetLinkedinAuthUrl.mockResolvedValueOnce( - "https://www.linkedin.com/oauth/v2/authorization?state=abc" + "https://www.linkedin.com/oauth/v2/authorization?state=abc", ); render(); @@ -244,7 +310,7 @@ describe("RegisterSide", () => { await waitFor(() => { expect(mockGetLinkedinAuthUrl).toHaveBeenCalled(); expect(window.location.href).toBe( - "https://www.linkedin.com/oauth/v2/authorization?state=abc" + "https://www.linkedin.com/oauth/v2/authorization?state=abc", ); }); }); diff --git a/frontend/tests/unit/new_dashboard/jobs.test.tsx b/frontend/tests/unit/new_dashboard/jobs.test.tsx index acb688e..92680b3 100644 --- a/frontend/tests/unit/new_dashboard/jobs.test.tsx +++ b/frontend/tests/unit/new_dashboard/jobs.test.tsx @@ -77,6 +77,7 @@ describe("new_dashboard job components", () => { ); const filterGrid = searchInput.closest("label")?.parentElement; + expect(searchInput).toHaveAttribute("maxlength", "100"); expect(filterGrid).toHaveClass( "grid-cols-1", "sm:grid-cols-2", @@ -87,12 +88,9 @@ describe("new_dashboard job components", () => { expect(select).toHaveClass("w-full", "min-w-0"); }); - fireEvent.change( - searchInput, - { - target: { value: "react" }, - }, - ); + fireEvent.change(searchInput, { + target: { value: "react" }, + }); fireEvent.change(screen.getAllByRole("combobox")[0], { target: { value: "Remoto" }, }); @@ -119,11 +117,7 @@ describe("new_dashboard job components", () => { it("renderiza a tabela vazia e paginação local e remota", () => { const localRender = render( - , + , ); expect(screen.getByText(/nenhuma vaga encontrada/i)).toBeInTheDocument(); @@ -180,7 +174,9 @@ describe("new_dashboard job components", () => { const onOpen = vi.fn(); const onStatusChange = vi.fn(); - render(); + render( + , + ); fireEvent.click(screen.getAllByRole("button", { name: /detalhes/i })[0]); fireEvent.click(screen.getByRole("button", { name: /salvar/i })); @@ -222,11 +218,7 @@ describe("new_dashboard job components", () => { ]; render( - , + , ); const visibleTitles = () => @@ -371,9 +363,12 @@ describe("new_dashboard job components", () => { fireEvent.change(screen.getByPlaceholderText(/r\$ 8.000 - r\$ 10.000/i), { target: { value: "€ 4.000" }, }); - fireEvent.change(screen.getByPlaceholderText(/react, typescript, node.js/i), { - target: { value: "NestJS, PostgreSQL" }, - }); + fireEvent.change( + screen.getByPlaceholderText(/react, typescript, node.js/i), + { + target: { value: "NestJS, PostgreSQL" }, + }, + ); fireEvent.change(screen.getByPlaceholderText(/linkedin, gupy/i), { target: { value: "Gupy" }, }); @@ -407,8 +402,7 @@ describe("new_dashboard job components", () => { function Harness() { const [searchQuery, setSearchQuery] = useState(""); - const [filterType, setFilterType] = - useState("Todos"); + const [filterType, setFilterType] = useState("Todos"); const [filterLevel, setFilterLevel] = useState("Todos"); const [continentFilter, setContinentFilter] = useState("Todos");