From 3cc9ac1ea5c15809b90cad94bb6ec4f00882e1cc Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 11:04:33 -0300 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20corre=C3=A7=C3=A3o=20do=20campo=20de?= =?UTF-8?q?=20telefone=20=20BR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RegisterFormPanel.tsx | 201 +++++++++++++++--- .../components/login/RegisterSide.test.tsx | 83 +++++++- .../new_dashboard/branch-coverage.test.tsx | 30 ++- .../unit/new_dashboard/home.profile.test.tsx | 20 +- 4 files changed, 292 insertions(+), 42 deletions(-) diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index ab5b951..3f2245a 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -1,11 +1,9 @@ +import { useTheme } from "@/shared/hooks/useTheme"; +import { ThemeToggle } from "@/shared/ui/theme-toggle"; import { Image } from "@unpic/react"; import { motion } from "framer-motion"; import { ArrowLeft, Eye, EyeOff } from "lucide-react"; import { FormEvent, useState } from "react"; -import PhoneInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; -import { ThemeToggle } from "@/shared/ui/theme-toggle"; -import { useTheme } from "@/shared/hooks/useTheme"; import { getGithubAuthUrl, @@ -36,11 +34,136 @@ const LEVEL_OPTIONS = [ const REGISTER_LIMITS = { name: 100, email: 254, - phoneDigits: 15, + phoneDigitsWithCountryCode: 13, + phoneDigitsMobileWithoutCountryCode: 11, + phoneDigitsLandlineWithoutCountryCode: 10, + phoneInput: 19, password: 128, cpf: 14, } as const; +const PHONE_ALLOWED_CHARS_REGEX = /^[0-9+()\s-]*$/; +const BRAZIL_MOBILE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}9\d{8}$/; +const BRAZIL_LANDLINE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}[2-8]\d{7}$/; + +function formatBrazilianPhoneFromDigits(digits: string) { + const ddd = digits.slice(0, 2); + const subscriber = digits.slice(2); + + if (digits.length <= 2) return `(${ddd}`; + + const isMobile = digits.length === 11 || subscriber.startsWith("9"); + if (isMobile) { + if (subscriber.length <= 5) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 5)}-${subscriber.slice(5, 9)}`; + } + + if (subscriber.length <= 4) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 4)}-${subscriber.slice(4, 8)}`; +} + +function normalizePhoneInput(rawValue: string) { + const trimmed = rawValue.trim(); + const digits = trimmed.replace(/\D/g, ""); + const hasExplicitPlus = trimmed.startsWith("+"); + + if (hasExplicitPlus && !digits.startsWith("55")) { + return { + masked: `+${digits.slice(0, REGISTER_LIMITS.phoneDigitsWithCountryCode)}`, + }; + } + + const inferredCountryCode = + hasExplicitPlus || (digits.startsWith("55") && digits.length > 11); + + let nationalDigits = inferredCountryCode ? digits.slice(2) : digits; + const defaultMaxDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + const maxNationalDigits = Math.min( + defaultMaxDigits, + REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode, + ); + + nationalDigits = nationalDigits.slice(0, maxNationalDigits); + const maskedNational = nationalDigits + ? formatBrazilianPhoneFromDigits(nationalDigits) + : ""; + + return { + masked: inferredCountryCode + ? `+55${maskedNational ? ` ${maskedNational}` : ""}` + : maskedNational, + }; +} + +function sanitizePhoneForValidation(value: string) { + const trimmed = value.trim(); + const plusCount = (trimmed.match(/\+/g) || []).length; + + if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) { + return { valid: false as const }; + } + + if (plusCount > 1 || (plusCount === 1 && !trimmed.startsWith("+"))) { + return { valid: false as const }; + } + + const compact = trimmed.replace(/[\s()-]/g, ""); + if (/[^\d+]/.test(compact)) { + return { valid: false as const }; + } + + const hasCountryCode = compact.startsWith("+"); + if (hasCountryCode && !compact.startsWith("+55")) { + return { valid: false as const }; + } + + if (hasCountryCode && !/^\+55\d+$/.test(compact)) { + return { valid: false as const }; + } + + const digitsOnly = compact.replace(/\D/g, ""); + const nationalDigits = hasCountryCode ? digitsOnly.slice(2) : digitsOnly; + const isMobileLength = + nationalDigits.length === REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode; + const isLandlineLength = + nationalDigits.length === + REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + if (!isMobileLength && !isLandlineLength) { + return { valid: false as const }; + } + + if ( + hasCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode - 1 + ) { + return { valid: false as const }; + } + + const ddd = nationalDigits.slice(0, 2); + if (!/^[1-9]{2}$/.test(ddd)) { + return { valid: false as const }; + } + + const candidate = hasCountryCode ? `+55${nationalDigits}` : nationalDigits; + const isValidMobile = BRAZIL_MOBILE_PHONE_REGEX.test(candidate); + const isValidLandline = BRAZIL_LANDLINE_PHONE_REGEX.test(candidate); + + if (!isValidMobile && !isValidLandline) { + return { valid: false as const }; + } + + return { + valid: true as const, + payload: hasCountryCode ? `+55${nationalDigits}` : nationalDigits, + }; +} + function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -131,6 +254,34 @@ export default function RegisterSide() { const handleRevealPassword = () => setShowPassword((prev) => !prev); + const handlePhoneChange = (rawValue: string) => { + const typed = rawValue ?? ""; + if (typed && !PHONE_ALLOWED_CHARS_REGEX.test(typed)) { + return; + } + + const digits = typed.replace(/\D/g, ""); + const trimmed = typed.trim(); + const hasCountryCode = + trimmed.startsWith("+") || (digits.startsWith("55") && digits.length > 11); + const nationalDigits = hasCountryCode ? digits.slice(2) : digits; + const maxNationalDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + const maxTotalDigits = hasCountryCode ? 2 + maxNationalDigits : maxNationalDigits; + + if (digits.length > maxTotalDigits) { + return; + } + + const normalized = normalizePhoneInput(typed); + setTelefone(normalized.masked); + if (telefoneError) { + setTelefoneError(""); + } + }; + const formatCpf = (value: string) => { const digits = value.replace(/\D/g, "").slice(0, 11); if (digits.length <= 3) return digits; @@ -168,9 +319,16 @@ export default function RegisterSide() { } } - if (!telefone) { - setTelefoneError("O campo de telefone é obrigatório."); - isValid = false; + const normalizedPhone = (telefone ?? "").trim(); + let phoneToSend: string | undefined; + if (normalizedPhone) { + const parsedPhone = sanitizePhoneForValidation(normalizedPhone); + if (!parsedPhone.valid) { + setTelefoneError("Informe um telefone brasileiro válido."); + isValid = false; + } else { + phoneToSend = parsedPhone.payload; + } } if (!password) { @@ -202,7 +360,7 @@ export default function RegisterSide() { email: email, password: password, name: nome, - phone: telefone, + phone: phoneToSend, cpf: cpf || undefined, level, }); @@ -305,26 +463,21 @@ export default function RegisterSide() {
- { - if ( - !value || - value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits - ) { - setTelefone(value); - } - }} - numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }} + handlePhoneChange(e.target.value)} + maxLength={REGISTER_LIMITS.phoneInput} disabled={isLoading} - className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom" + className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none" placeholder="(34) 23456-7890" />
diff --git a/frontend/tests/unit/components/login/RegisterSide.test.tsx b/frontend/tests/unit/components/login/RegisterSide.test.tsx index 0486826..ab87594 100644 --- a/frontend/tests/unit/components/login/RegisterSide.test.tsx +++ b/frontend/tests/unit/components/login/RegisterSide.test.tsx @@ -26,6 +26,21 @@ function fillRequiredRegisterFields() { }); } +function fillRequiredRegisterFieldsWithoutPhone() { + fireEvent.change(screen.getByLabelText(/nome/i), { + target: { value: "Bene" }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "bene@teste.com" }, + }); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "12345678" }, + }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { + target: { value: "pleno" }, + }); +} + vi.mock("@/domains/auth/infrastructure/authApi", () => ({ register: (...args: any[]) => mockRegister(...args), getGoogleAuthUrl: (...args: any[]) => mockGetGoogleAuthUrl(...args), @@ -110,7 +125,7 @@ describe("RegisterSide", () => { expect(screen.getByLabelText(/email/i)).toHaveAttribute("maxlength", "254"); expect(screen.getByPlaceholderText(/\(34\)/i)).toHaveAttribute( "maxlength", - "16", + "19", ); expect(screen.getByLabelText(/senha/i)).toHaveAttribute("maxlength", "128"); expect(screen.getByLabelText(/cpf/i)).toHaveAttribute("maxlength", "14"); @@ -139,9 +154,6 @@ describe("RegisterSide", () => { 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(); @@ -190,6 +202,69 @@ describe("RegisterSide", () => { expect(window.location.href).toBe("/login?registered=true"); }); + it("envia formulário válido sem telefone", async () => { + mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith({ + email: "bene@teste.com", + password: "12345678", + name: "Bene", + phone: undefined, + cpf: undefined, + level: "pleno", + }); + }); + }); + + it("rejeita telefone inválido quando preenchido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { + target: { value: "123" }, + }); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + expect( + await screen.findByText(/telefone brasileiro válido/i), + ).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("aplica máscara para celular e fixo brasileiros", () => { + render(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "11912345678" } }); + expect(phoneInput.value).toBe("(11) 91234-5678"); + + fireEvent.change(phoneInput, { target: { value: "1134567890" } }); + expect(phoneInput.value).toBe("(11) 3456-7890"); + + fireEvent.change(phoneInput, { target: { value: "5511912345678" } }); + expect(phoneInput.value).toBe("+55 (11) 91234-5678"); + }); + + it("bloqueia números maiores que o limite permitido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "+55 1891898989989999" } }); + expect(phoneInput.value).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith( + expect.objectContaining({ phone: undefined }), + ); + }); + }); + it("envia formulário válido com CPF como usuário (sem tecnologias/nível)", async () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 765865e..d449ffc 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -1,21 +1,21 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { CareerChecklist } from "@/domains/new_dashboard/components/home/CareerChecklist"; -import { Header } from "@/domains/new_dashboard/components/layout/Header"; -import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; +import { Header } from "@/domains/new_dashboard/components/layout/Header"; +import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/MentoringTab"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { - clearDashboardNotifications, - getDashboardNotificationFeed, - markDashboardNotificationsRead, + clearDashboardNotifications, + getDashboardNotificationFeed, + markDashboardNotificationsRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; import type { Job, UserProfile } from "@/domains/new_dashboard/types"; import { DASHBOARD_NOTIFICATIONS_REFRESH_EVENT } from "@/domains/new_dashboard/utils/notificationEvents"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockUseAuth = vi.fn(); const mockUseTheme = vi.fn(); @@ -73,6 +73,15 @@ const profileWithoutAvatar: UserProfile = { technologyExperiences: [{ name: "React", years: 2 }], }; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + describe("new_dashboard branch coverage", () => { beforeEach(() => { localStorage.clear(); @@ -299,6 +308,7 @@ describe("new_dashboard branch coverage", () => { it("usa o checklist com título padrão, enter e remoção da lista", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); expect( screen.getByText(/crie uma lista mensal para começar/i), @@ -306,7 +316,9 @@ describe("new_dashboard branch coverage", () => { fireEvent.click(screen.getByRole("button", { name: /^lista$/i })); expect( - screen.getByRole("button", { name: /checklist de julho de 2026/i }), + screen.getByRole("button", { + name: new RegExp(`checklist de ${currentMonthLabel}`, "i"), + }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { diff --git a/frontend/tests/unit/new_dashboard/home.profile.test.tsx b/frontend/tests/unit/new_dashboard/home.profile.test.tsx index 93df62b..b08707d 100644 --- a/frontend/tests/unit/new_dashboard/home.profile.test.tsx +++ b/frontend/tests/unit/new_dashboard/home.profile.test.tsx @@ -3,18 +3,27 @@ import { HomeTab } from "@/domains/new_dashboard/components/home/HomeTab"; import { PreferencesForm } from "@/domains/new_dashboard/components/profile/PreferencesForm"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { - initialPreferences, - initialUser, + initialPreferences, + initialUser, } from "@/domains/new_dashboard/constants"; import type { - SearchPreferences, - UserProfile, + SearchPreferences, + UserProfile, } from "@/domains/new_dashboard/types"; import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + function renderWithProfileState( ui: (props: { userProfile: UserProfile; @@ -132,6 +141,7 @@ describe("new_dashboard home and profile components", () => { it("permite criar lista, adicionar item, marcar e excluir no CareerChecklist", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); fireEvent.change(screen.getByPlaceholderText(/nome da nova lista/i), { target: { value: "Metas" }, @@ -140,7 +150,7 @@ describe("new_dashboard home and profile components", () => { expect(screen.getByRole("button", { name: /metas/i })).toBeInTheDocument(); expect( - screen.getByText("julho de 2026", { selector: "span" }), + screen.getByText(currentMonthLabel, { selector: "span" }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { From ce6346de7d553cb2d4a2af91a95465992210f993 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 11:28:28 -0300 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20adicionando=20documenta=C3=A7=C3=A3?= =?UTF-8?q?o=20para=20iniciantes=20no=20projeto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LOCAL_DEVELOPMENT.md | 651 +++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 LOCAL_DEVELOPMENT.md diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..315c6ac --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,651 @@ +# Guia de Desenvolvimento Local + +Este guia foi escrito para quem acabou de clonar o repositório e precisa subir o projeto do zero. + +Objetivo: permitir execução local com o mínimo de tentativa e erro, usando apenas o que existe hoje no repositório. + +## Visão rápida + +O monorepo possui 5 blocos principais: + +- frontend: aplicação principal do usuário final (React + Vite) +- backend: API Node.js/Express (TypeScript + Drizzle) +- front_admin: painel administrativo (React + Vite) +- scraper-go: serviço Go para coleta/agregação de vagas +- observability: stack de métricas e logs (Prometheus/Grafana/Loki etc.) + +Além disso, há Docker Compose para infraestrutura e execução completa. + +--- + +## 1) Pré-requisitos + +### Obrigatórios + +1. Git +2. Node.js 22+ (o backend exige >= 22) +3. npm (projeto usa package-lock e scripts npm) +4. Docker Desktop com Docker Compose (recomendado para subir stack completa) + +### Opcionais (dependendo do fluxo) + +1. Go 1.26+ (apenas se você quiser rodar o scraper-go fora do Docker) +2. PostgreSQL local (se quiser rodar backend local sem backend em container) +3. Valkey/Redis local (se quiser rodar backend/scraper local fora de container) + +### Como verificar instalação + +No terminal: + +```bash +git --version +node -v +npm -v +docker --version +docker compose version +``` + +Opcional (Go): + +```bash +go version +``` + +### Versão recomendada de gerenciador de pacotes + +- Recomendado pelo projeto: npm +- Observação: existe pnpm-workspace.yaml, mas o lockfile ativo do projeto é package-lock.json. + +--- + +## 2) Clonando o projeto + +Exemplo: + +```bash +git clone https://github.com/Cla-Code-Community/candidate.git +cd candidate +``` + +Se seu fork/repo tiver outro nome, ajuste os comandos. + +--- + +## 3) Estrutura do monorepo + +Estrutura de alto nível relevante: + +- backend/ + - API Express, rotas de auth/users/jobs/keywords/saved-jobs/admin + - migrações em backend/drizzle + - testes unitários e integração em backend/tests +- frontend/ + - app principal (landing, login/cadastro, callback OAuth, dashboard) + - testes em frontend/tests +- front_admin/ + - painel administrativo (dashboard, usuários, scrapers, observabilidade, auditoria, permissões) + - testes em front_admin/tests +- scraper-go/ + - serviço Go de scraping + - endpoints como /scrape, /health, /metrics, /api/keywords +- shared/ + - componentes compartilhados (ex.: CandidateLogo) +- docker/ + - Dockerfile multi-stage para backend/frontend/front_admin +- observability/ + - arquivos de configuração do Prometheus/Grafana/Loki/Alertmanager etc. +- docs/ + - documentação complementar +- docker-compose.infra.yml + - PostgreSQL + Valkey +- docker-compose.yml + - scraper-go + backend + frontend + front_admin +- docker-compose.migrate.yml + - job de migração/backfill antes do backend +- docker-compose.observability.yml + - stack de observabilidade + +--- + +## 4) Instalação + +Execute na raiz do monorepo: + +```bash +npm install +``` + +Isso instala dependências da raiz e dos workspaces. + +--- + +## 5) Variáveis de ambiente + +## Arquivos de ambiente existentes + +No estado atual do repositório, existem: + +- .env.example (raiz) +- backend/.env.example +- frontend/.env.example + +Também podem existir localmente após setup: + +- .env +- backend/.env + +Observação importante: + +- front_admin não possui front_admin/.env.example versionado. + +## Como criar + +Na raiz: + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env +``` + +No PowerShell: + +```powershell +Copy-Item .env.example .env +Copy-Item backend/.env.example backend/.env +Copy-Item frontend/.env.example frontend/.env +``` + +## Variáveis obrigatórias vs opcionais + +### Obrigatórias na prática para uso completo + +1. SESSION_SECRET +2. DATABASE_URL +3. CORS_ALLOWED_ORIGINS +4. FRONTEND_URL +5. GO_SCRAPER_URL (ou SCRAPER_URL em alguns fluxos) + +### Necessárias somente se usar OAuth + +1. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET +2. GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET +3. LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET + +### Necessárias somente para fontes externas de scraping + +1. ADZUNA_APP_ID / ADZUNA_APP_KEY +2. JOOBLE_API_KEY + +### Segurança/PII + +1. ENCRYPTION_MASTER_KEY +2. SEARCH_KEY +3. ENCRYPTION_KEY_ID + +Se esses valores não estiverem definidos adequadamente, recursos que dependem de criptografia e busca segura podem falhar. + +--- + +## 6) Banco de dados + +## O que existe hoje + +- ORM: Drizzle +- Dialeto: PostgreSQL +- Migrações: backend/drizzle +- Config Drizzle: backend/drizzle.config.js +- Não há arquivos de seeders versionados no repositório. + +## Migrações (manual) + +Rodando no workspace backend: + +```bash +npm run db:migrate --workspace=backend +``` + +Alternativas: + +```bash +npm run db:generate --workspace=backend +npm run db:push --workspace=backend +``` + +## Migrações via Docker + +No fluxo Docker completo, há o serviço migrate em docker-compose.migrate.yml que roda: + +- db:migrate +- security:backfill-user-pii + +## Usuários padrão + +Não há usuários padrão documentados como seed no repositório. + +Como criar usuário para testes: + +1. Use a tela de cadastro em /register +2. Ou envie POST /auth/register + +--- + +## 7) Docker + +## 7.1 Subir stack completa (recomendado para onboarding) + +1. Criar rede: + +```bash +docker network create vagas-net +``` + +2. Subir infra + app + migrate: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.2 Parar stack + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml down +``` + +## 7.3 Rebuild + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.4 Logs + +Logs de todos os serviços: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f +``` + +Logs de um serviço específico (exemplo backend): + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f backend +``` + +## 7.5 Observabilidade (opcional) + +Subir stack de observabilidade: + +```bash +docker compose -f docker-compose.observability.yml up -d +``` + +Parar: + +```bash +docker compose -f docker-compose.observability.yml down +``` + +--- + +## 8) Executando o projeto + +Você tem 2 caminhos principais. + +## Caminho A: Docker completo (mais simples para começar) + +Use o comando da seção de Docker. + +Portas esperadas: + +- frontend: http://localhost:5173 +- front_admin: http://localhost:5174 +- backend: http://localhost:3001 +- scraper-go: http://localhost:8081 + +## Caminho B: Node local (frontend + backend) + +Na raiz: + +```bash +npm run dev +``` + +Isso sobe: + +- frontend em 5173 +- backend em 3001 + +Para incluir admin junto: + +```bash +npm run dev:admin +``` + +Comandos separados: + +```bash +npm run dev:frontend +npm run dev:backend +npm run dev:front_admin +``` + +Observação importante para o Caminho B: + +- Se backend estiver fora de container, DATABASE_URL e VALKEY_URL precisam apontar para serviços acessíveis pelo host. +- No compose de infra atual, PostgreSQL e Valkey não estão expostos por portas no host por padrão. +- Portanto, para backend local funcionar, você precisa: + 1. usar banco/valkey locais no host, ou + 2. expor portas no compose (ajuste manual), ou + 3. rodar backend também em container. + +--- + +## 9) Acessando a aplicação + +URLs principais: + +- App principal: http://localhost:5173 +- Login: http://localhost:5173/login +- Cadastro: http://localhost:5173/register +- Dashboard app: /home, /dashboard, /vagas, /mentoria, /perfil, /ajuda +- Callback OAuth: /auth/callback + +Backend: + +- Health: http://localhost:3001/health +- Swagger: http://localhost:3001/docs +- Metrics: http://localhost:3001/metrics + +Scraper: + +- Health: http://localhost:8081/health +- Metrics: http://localhost:8081/metrics +- Admin jobs count: http://localhost:8081/admin/jobs/count + +Front admin: + +- http://localhost:5174 +- rota de login: /login +- rotas principais: /dashboard, /users, /scrapers, /observability, /audit, /permissions, /settings + +## Login/senha padrão + +Não há credenciais padrão versionadas/documentadas para produção/local no repositório. + +Fluxo recomendado para ambiente local: + +1. criar usuário via cadastro na aplicação principal +2. usar login com email/senha criados + +Para OAuth, é necessário configurar credenciais de provedores no .env. + +--- + +## 10) Fluxo da aplicação (visão funcional) + +## Aplicação principal (frontend) + +1. Landing page pública em / +2. Cadastro em /register +3. Login em /login +4. Callback OAuth em /auth/callback +5. Após autenticação, acesso a rotas protegidas: + - /home + - /dashboard + - /vagas + - /mentoria + - /perfil + - /ajuda + +## Backend + +- Sessão via cookie (iron-session) +- Rotas protegidas para usuários autenticados: + - /users + - /jobs + - /keywords + - /notifications + - /saved-jobs + - /admin + +## Scraper e fila + +- Backend pode enfileirar keywords no Valkey (chave scraper:keywords:pending) +- Scraper-go processa keywords e agrega vagas + +## Front admin + +- Login próprio do painel +- Controle de acesso por papel (support/admin/super_admin) +- Seções administrativas para operação da plataforma + +--- + +## 11) Testando manualmente (roteiro prático) + +Abaixo, os testes manuais sugeridos para os módulos principais. + +## 11.1 Login + +Passos: + +1. Acesse http://localhost:5173/login +2. Tente enviar vazio +3. Informe credenciais inválidas +4. Informe credenciais válidas + +Resultado esperado: + +- validações de campo aparecem +- credenciais inválidas não autenticam +- credenciais válidas redirecionam para área protegida + +## 11.2 Cadastro + +Passos: + +1. Acesse http://localhost:5173/register +2. Preencha campos obrigatórios +3. Teste telefone opcional vazio +4. Teste telefone válido +5. Teste telefone inválido + +Resultado esperado: + +- cadastro válido cria conta e redireciona para login +- telefone vazio é permitido +- telefone inválido exibe erro e bloqueia envio + +## 11.3 Busca de vagas + +Passos: + +1. Faça login +2. Vá para /vagas +3. Acione busca/filtros + +Resultado esperado: + +- requests de busca retornam sem quebrar a UI +- estados de loading/erro são exibidos corretamente + +## 11.4 Vagas salvas + +Passos: + +1. Em /vagas, salve uma vaga +2. Abra lista de salvas +3. Edite status/notas se disponível +4. Remova vaga salva + +Resultado esperado: + +- operações de criar/editar/remover refletem na interface + +## 11.5 Perfil e preferências + +Passos: + +1. Vá para /perfil +2. Atualize dados do perfil +3. Atualize preferências + +Resultado esperado: + +- alterações persistem +- recarregar a tela mantém dados + +## 11.6 Painel administrativo + +Passos: + +1. Acesse http://localhost:5174/login +2. Faça login com conta com permissão +3. Navegue por dashboard/users/scrapers/observability/audit/permissions/settings + +Resultado esperado: + +- acesso a páginas conforme papel +- usuário sem papel mínimo deve cair em 403 + +--- + +## 12) Como reproduzir bugs corretamente + +Use sempre este formato: + +1. Contexto + - branch + - commit + - ambiente (Docker ou local) + - variáveis relevantes +2. Passos para reproduzir + - sequenciais e exatos +3. Resultado atual +4. Resultado esperado +5. Evidências + - print, log, request/response, stack trace + +Modelo: + +- Passos: + 1. ... + 2. ... + 3. ... +- Resultado atual: ... +- Resultado esperado: ... + +Exemplo real (telefone): + +- Passos: + 1. abrir /register + 2. inserir telefone muito longo + 3. tentar enviar +- Resultado atual (bug): campo aceitava valor inválido +- Resultado esperado: bloquear dígitos excedentes e rejeitar telefone inválido + +--- + +## 13) Testes automatizados + +## 13.1 Monorepo (cobertura consolidada) + +Na raiz: + +```bash +npm run test:coverage +``` + +## 13.2 Backend + +```bash +npm run test --workspace=backend +npm run test:coverage --workspace=backend +npm run test:watch --workspace=backend +``` + +## 13.3 Frontend + +```bash +npm run test --workspace=frontend +npm run test:coverage --workspace=frontend +npm run test:watch --workspace=frontend +``` + +## 13.4 Front admin + +```bash +npm run test --workspace=front_admin +npm run test:coverage --workspace=front_admin +``` + +## 13.5 Testes de integração e E2E + +- Integração: existe no backend (backend/tests/integration). +- E2E browser (Playwright/Cypress): não há suíte E2E ativa/versionada no estado atual do repositório. + +--- + +## 14) Checklist antes de abrir Pull Request + +Use esta checklist: + +- [ ] Projeto instala do zero (npm install) +- [ ] App sobe localmente (npm run dev) ou Docker completo +- [ ] Backend responde /health +- [ ] Frontend abre sem erro crítico +- [ ] Testes do escopo alterado passando +- [ ] Cobertura mantida para o escopo afetado +- [ ] Lint sem erros no frontend/front_admin +- [ ] Sem erro de TypeScript no escopo alterado +- [ ] Funcionalidade validada manualmente +- [ ] Sem regressões observáveis +- [ ] Logs limpos (sem erro não tratado) + +--- + +## Comandos úteis extras + +Builds: + +```bash +npm run build:frontend +npm run build:front_admin +``` + +Validação rápida da raiz: + +```bash +npm run validate +``` + +Electron: + +```bash +npm run electron +npm run electron:dev +``` + +--- + +## Referências do projeto + +- README.md (visão geral) +- BACKEND.md (detalhes da API) +- SCRAPER.md (detalhes do scraper-go) +- TESTING.md (roteiro de QA) +- frontend/README.md +- front_admin/README.md + +--- + +## Lacunas identificadas no estado atual (sem suposição) + +1. Não há front_admin/.env.example versionado. +2. Não há seed oficial versionado para usuários/dados iniciais. +3. Não há credenciais padrão oficiais documentadas para login local. +4. Não há suíte E2E browser ativa/versionada. +5. Há documentação antiga em alguns pontos com prefixo /api que pode divergir das rotas montadas em runtime (que usam /auth, /users, /jobs, etc.). + +Se você for manter este guia, priorize resolver essas lacunas para reduzir tempo de onboarding. diff --git a/README.md b/README.md index 3d419c9..0fe7ce1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Painel de Vagas -[![CI](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml/badge.svg)](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml) +> Novo no projeto? Comece por aqui: [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) + +[![CI](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml/badge.svg)](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml) ![Node >= 22](https://img.shields.io/badge/node-%3E%3D22-339933) ![Monorepo](https://img.shields.io/badge/architecture-monorepo-0A66C2) ![License ISC](https://img.shields.io/badge/license-ISC-lightgrey) From 47116b3f15861daea4e79cdeee3d3f48d5184a38 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 12:01:36 -0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(PAV-114):=20adiciona=20documenta=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20ambiente=20local=20e=20padr=C3=A3o=20de=20testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GUIA_TESTES_AUTOMATIZADOS.md | 337 ++++++++++++++++++ .../unit/adapters/TP-01.goScraper.test.ts | 57 +++ backend/tests/unit/adapters/goScraper.test.ts | 134 ------- .../adapters/goScrapper/goScraper.TC.types.ts | 38 ++ .../adapters/goScrapper/goScraper.TC01.ts | 36 ++ .../adapters/goScrapper/goScraper.TC02.ts | 26 ++ .../adapters/goScrapper/goScraper.TC03.ts | 29 ++ .../adapters/goScrapper/goScraper.TC04.ts | 14 + .../adapters/goScrapper/goScraper.TC05.ts | 31 ++ .../adapters/goScrapper/goScraper.TC06.ts | 42 +++ 10 files changed, 610 insertions(+), 134 deletions(-) create mode 100644 GUIA_TESTES_AUTOMATIZADOS.md create mode 100644 backend/tests/unit/adapters/TP-01.goScraper.test.ts delete mode 100644 backend/tests/unit/adapters/goScraper.test.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts diff --git a/GUIA_TESTES_AUTOMATIZADOS.md b/GUIA_TESTES_AUTOMATIZADOS.md new file mode 100644 index 0000000..4ce20f6 --- /dev/null +++ b/GUIA_TESTES_AUTOMATIZADOS.md @@ -0,0 +1,337 @@ +# Guia de Testes Automatizados + +Este documento foi criado para quem nunca trabalhou com testes automatizados. + +Se voce esta contribuindo pela primeira vez no projeto, começe por aqui. + +## O que e um teste automatizado? + +Um teste automatizado e um pequeno programa que verifica se uma funcionalidade continua funcionando como esperado. + +Em vez de testar tudo manualmente toda vez, o teste automatizado executa cenarios e confirma o resultado por voce. + +## Por que escrevemos testes? + +Escrevemos testes para: + +- evitar quebrar funcionalidades antigas +- dar seguranca para melhorar o codigo +- documentar como o sistema deve se comportar +- facilitar revisao de Pull Request +- ajudar novos contribuidores a entender o projeto + +## O que acontece quando um teste falha? + +Quando um teste falha, isso e um alerta. + +Pode significar: + +- bug novo +- comportamento mudou sem querer +- regra esperada deixou de ser atendida + +Em geral, um teste falhando e uma protecao do projeto, nao um incomodo. + +## Como testes ajudam novos desenvolvedores + +Testes funcionam como documentacao viva. + +Ao ler um teste, voce entende: + +- qual entrada foi usada +- qual comportamento era esperado +- o que nao pode regredir + +--- + +## Conceitos basicos + +### O que e um cenario de teste? + +Cenario de teste e uma situacao real do sistema. + +Exemplo simples: + +"Quando faco login com senha correta, espero entrar no sistema." + +Isso vira: + +- Cenario: usuario informa senha correta +- Resultado esperado: sistema permite acesso + +--- + +## O que e um TC (Test Case) + +TC significa Test Case (Caso de Teste). + +Um TC responde: + +"Qual comportamento do sistema estou garantindo?" + +Importante: + +- TC nao e uma linha de codigo isolada +- TC e um comportamento relevante + +Exemplo real do projeto: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Responsabilidade desse TC: + +- garantir que o adapter retorna vagas quando o Go Scraper responde com dados validos + +--- + +## O que e um TP (Test Plan) + +TP significa Test Plan (Plano de Testes). + +TP e um documento/arquivo que organiza: + +- o que sera testado +- quais cenarios existem +- quais TCs pertencem ao plano +- resultados esperados + +Exemplo real do projeto: + +- backend/tests/unit/adapters/TP-01.goScraper.test.ts + +Esse arquivo representa o planejamento dos testes do adapter Go Scraper. + +Ele define: + +- qual funcionalidade sera validada +- qual o objetivo dos testes +- quais cenarios precisam ser protegidos +- quais TCs fazem parte desse plano + +Antes de escrever codigo de teste, o primeiro passo e entender quais comportamentos precisam ser garantidos. + +--- + +## Relacao entre TP e TC + +O projeto utiliza esta relacao: + +TP (Test Plan) +-> +TC (Test Case) +-> +Teste automatizado executavel + +Explicando de forma simples: + +- TP: planeja os cenarios +- TC: implementa cada cenario +- Suite de testes: executa tudo e valida o comportamento + +### Estrutura de relacionamento (Go Scraper) + +```text +Go Scraper Adapter + +TP-01.goScraper.test.ts + +Define os cenarios: + +|- TC-01 +| |- Resposta valida +| +|- TC-02 +| |- Erro HTTP +| +|- TC-03 +| |- Contrato invalido +| +|- TC-04 +| |- Parametros invalidos +| +|- TC-05 +| |- Envio de parametros +| +|- TC-06 +| |- Variavel de ambiente +``` + +--- + +## Explicacao dos TCs reais do projeto + +Arquivos reais atualmente: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts + +Resumo de cada cenario: + +- TC01 + - Cenario: resposta valida do Go Scraper + - Esperado: adapter retorna vagas corretamente + +- TC02 + - Cenario: servico externo retorna erro HTTP + - Esperado: adapter trata e propaga erro corretamente + +- TC03 + - Cenario: resposta nao segue contrato esperado + - Esperado: sistema rejeita resposta invalida + +- TC04 + - Cenario: parametros invalidos de entrada + - Esperado: falha antes de chamar servico externo + +- TC05 + - Cenario: envio de parametros para o servico + - Esperado: payload preserva dados esperados + +- TC06 + - Cenario: URL do Go Scraper via variavel de ambiente + - Esperado: adapter usa GO_SCRAPER_URL configurada + +--- + +## Primeiro exemplo do projeto (passo a passo) + +Use como referencia: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Passo a passo do que ele faz: + +1. prepara uma resposta simulada (mock) da API externa +2. executa o adapter +3. verifica os dados retornados +4. confirma que o comportamento esperado aconteceu + +--- + +## Regra de qualidade dos testes + +Teste unitario nao deve validar apenas uma linha ou chamada isolada. + +Exemplo ruim: + +```ts +it("deve chamar funcao", () => { + expect(mock.execute).toHaveBeenCalled(); +}); +``` + +Por que e ruim: + +- valida detalhe interno +- nao prova comportamento real + +Exemplo melhor: + +```ts +it("deve retornar vagas quando a resposta do Go Scraper for valida", async () => { + // cenario completo +}); +``` + +Pergunta que todo bom teste deve responder: + +"Qual comportamento do sistema estou garantindo?" + +--- + +## Quando usar varios testes e quando usar it.each + +Use TCs separados quando os cenarios sao diferentes. + +Exemplo: + +- resposta valida +- erro HTTP +- contrato invalido + +Use `it.each` quando a regra e a mesma e so os dados mudam. + +Exemplo: + +```ts +it.each([ + { keyword: "Java" }, + { keyword: "Node.js" }, +])( "deve buscar vagas corretamente", async ({ keyword }) => { + // mesma regra, dados diferentes +}); +``` + +Isso evita copiar e colar testes quase iguais. + +--- + +## Processo para criar novos testes + +1. Identificar a funcionalidade +2. Criar ou atualizar o TP +3. Definir cenarios +4. Criar TCs necessarios +5. Implementar os testes +6. Executar testes localmente +7. Abrir Pull Request + +Comando util para backend: + +```bash +npm run test --workspace=backend +``` + +--- + +## Beneficios desse padrao + +Para iniciantes: + +- facilita entender o projeto +- funciona como documentacao viva +- ajuda na primeira contribuicao + +Para o projeto: + +- melhora manutencao +- facilita revisao de PR +- reduz regressao +- mantem qualidade com crescimento da comunidade + +--- + +## Checklist para novos contribuidores + +Antes de abrir PR, confirme: + +- Existe um TP documentando o objetivo? +- O TC representa um comportamento real? +- O nome do teste esta claro? +- Evitei testar detalhes internos? +- Usei it.each quando os cenarios sao equivalentes? + +--- + +## Nota de consistencia sobre nomes dos arquivos + +Este guia usa os caminhos reais encontrados no repositorio no momento da escrita. + +No prompt de referencia, alguns nomes aparecem como: + +- TC-01.goScraper.test.ts +- TP-01.goScraper.test.ts (como documento) + +No codigo atual, os arquivos de TC estao nomeados como: + +- goScraper.TC01.ts ate goScraper.TC06.ts + +Isso nao muda o conceito. + +O importante e manter a relacao: + +TP planeja -> TC implementa -> teste automatizado protege comportamento. diff --git a/backend/tests/unit/adapters/TP-01.goScraper.test.ts b/backend/tests/unit/adapters/TP-01.goScraper.test.ts new file mode 100644 index 0000000..e4652c8 --- /dev/null +++ b/backend/tests/unit/adapters/TP-01.goScraper.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, vi } from "vitest"; +import { TC01 } from "./goScrapper/goScraper.TC01.ts"; +import { TC02 } from "./goScrapper/goScraper.TC02.ts"; +import { TC03 } from "./goScrapper/goScraper.TC03.ts"; +import { TC04 } from "./goScrapper/goScraper.TC04.ts"; +import { TC05 } from "./goScrapper/goScraper.TC05.ts"; +import { TC06 } from "./goScrapper/goScraper.TC06.ts"; + +const mocks = vi.hoisted(() => ({ + logWarn: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("../../../src/logger.ts", () => ({ + logWarn: mocks.logWarn, +})); + +import { searchJobs } from "../../../src/adapters/goScraper.ts"; + +const validParams = { + keywords: ["Java", "Node.js"], + location: "Brasil", +}; + +const validResponse = { + jobs: [ + { + id: "1", + title: "Dev", + company: "ACME", + location: "Brasil", + url: "https://example.com/job/1", + source: "LinkedIn", + }, + ], + total: 1, + cachedAt: "2026-01-01T00:00:00Z", + fromCache: false, +}; + +describe("goScraper", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mocks.fetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + TC01({ searchJobs, validParams, validResponse, mocks }); + TC02({ searchJobs, validParams, mocks }); + TC03({ searchJobs, validParams, mocks }); + TC04({ searchJobs, mocks }); + TC05({ searchJobs, validResponse, mocks }); + TC06({ validParams, validResponse, mocks }); +}); diff --git a/backend/tests/unit/adapters/goScraper.test.ts b/backend/tests/unit/adapters/goScraper.test.ts deleted file mode 100644 index 8b474a1..0000000 --- a/backend/tests/unit/adapters/goScraper.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - logWarn: vi.fn(), - fetch: vi.fn(), -})); - -vi.mock("../../../src/logger.ts", () => ({ - logWarn: mocks.logWarn, -})); - -import { searchJobs } from "../../../src/adapters/goScraper.ts"; - -const validParams = { - keywords: ["Java", "Node.js"], - location: "Brasil", -}; - -const validResponse = { - jobs: [ - { - id: "1", - title: "Dev", - company: "ACME", - location: "Brasil", - url: "https://example.com/job/1", - source: "LinkedIn", - }, - ], - total: 1, - cachedAt: "2026-01-01T00:00:00Z", - fromCache: false, -}; - -describe("goScraper", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", mocks.fetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("returns jobs when response is valid", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const result = await searchJobs(validParams); - - expect(result.total).toBe(1); - expect(result.jobs[0].title).toBe("Dev"); - expect(mocks.fetch).toHaveBeenCalledWith( - expect.stringContaining("/scrape"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - }); - - it("throws when response is not ok", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: 500 Internal Server Error", - ); - }); - - it("throws when response does not match schema", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ invalid: "data" }), - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: resposta invalida", - ); - expect(mocks.logWarn).toHaveBeenCalledWith( - "Go scraper: resposta fora do contrato", - expect.objectContaining({ error: expect.any(String) }), - ); - }); - - it("throws when params are invalid", async () => { - await expect(searchJobs({ keywords: [] })).rejects.toThrow(); - expect(mocks.fetch).not.toHaveBeenCalled(); - }); - - it("sends validated params to Go scraper", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - await searchJobs({ - keywords: ["Java", " Java ", "Node.js"], - location: "SP", - }); - - const body = JSON.parse(mocks.fetch.mock.calls[0][1].body); - expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); - expect(body.location).toBe("SP"); - }); - - it("uses GO_SCRAPER_URL from environment", async () => { - process.env.GO_SCRAPER_URL = "http://custom-go:9999"; - vi.resetModules(); - - vi.stubGlobal("fetch", mocks.fetch); - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const { searchJobs: searchJobsFresh } = - await import("../../../src/adapters/goScraper.ts"); - await searchJobsFresh(validParams); - - expect(mocks.fetch).toHaveBeenCalledWith( - "http://custom-go:9999/scrape", - expect.anything(), - ); - - delete process.env.GO_SCRAPER_URL; - vi.resetModules(); - }); -}); diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts new file mode 100644 index 0000000..ffde4e0 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts @@ -0,0 +1,38 @@ +export interface GoScraperJob { + id: string; + title: string; + company: string; + location: string; + url: string; + source: string; +} + +export interface GoScraperValidParams { + keywords: string[]; + location: string; +} + +export interface GoScraperValidResponse { + jobs: GoScraperJob[]; + total: number; + cachedAt: string; + fromCache: boolean; +} + +export type SearchJobs = (params: { + keywords: string[]; + location?: string; +}) => Promise; + +export interface GoScraperMocks { + logWarn: { + mockClear: () => void; + }; + fetch: { + mockResolvedValueOnce: (value: unknown) => void; + mockClear: () => void; + mock: { + calls: unknown[][]; + }; + }; +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts new file mode 100644 index 0000000..6a4aa82 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC01Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC01({ searchJobs, validParams, validResponse, mocks }: TC01Deps) { + it("deve retornar vagas quando o Go Scraper responder com dados válidos", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const result = await searchJobs(validParams); + + expect(result.total).toBe(validResponse.total); + expect(result.jobs).toHaveLength(validResponse.jobs.length); + expect(result.jobs[0]).toMatchObject({ + id: validResponse.jobs[0].id, + title: validResponse.jobs[0].title, + company: validResponse.jobs[0].company, + location: validResponse.jobs[0].location, + url: validResponse.jobs[0].url, + source: validResponse.jobs[0].source, + }); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts new file mode 100644 index 0000000..c6e15c6 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC02Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC02({ searchJobs, validParams, mocks }: TC02Deps) { + it("deve falhar quando o Go Scraper responder com status de erro", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: 500 Internal Server Error", + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts new file mode 100644 index 0000000..002ee72 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC03Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC03({ searchJobs, validParams, mocks }: TC03Deps) { + it("deve rejeitar resposta inválida e registrar aviso de contrato", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "data" }), + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: resposta invalida", + ); + expect(mocks.logWarn).toHaveBeenCalledWith( + "Go scraper: resposta fora do contrato", + expect.objectContaining({ error: expect.any(String) }), + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts new file mode 100644 index 0000000..128004f --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import type { GoScraperMocks, SearchJobs } from "./goScraper.TC.types"; + +interface TC04Deps { + searchJobs: SearchJobs; + mocks: GoScraperMocks; +} + +export function TC04({ searchJobs, mocks }: TC04Deps) { + it("deve falhar com parâmetros inválidos sem chamar o serviço externo", async () => { + await expect(searchJobs({ keywords: [] })).rejects.toThrow(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts new file mode 100644 index 0000000..47c6e6b --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC05Deps { + searchJobs: SearchJobs; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC05({ searchJobs, validResponse, mocks }: TC05Deps) { + it("deve enviar payload válido ao Go Scraper preservando keywords e localização", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + await searchJobs({ + keywords: ["Java", " Java ", "Node.js"], + location: "SP", + }); + + const firstCall = mocks.fetch.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(firstCall[1].body); + expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); + expect(body.location).toBe("SP"); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts new file mode 100644 index 0000000..e766a26 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts @@ -0,0 +1,42 @@ +import { expect, it, vi } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, +} from "./goScraper.TC.types"; + +interface TC06Deps { + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC06({ validParams, validResponse, mocks }: TC06Deps) { + it("deve usar GO_SCRAPER_URL do ambiente ao montar a URL de scrape", async () => { + const previousGoScraperUrl = process.env.GO_SCRAPER_URL; + process.env.GO_SCRAPER_URL = "http://custom-go:9999"; + vi.resetModules(); + + vi.stubGlobal("fetch", mocks.fetch); + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const { searchJobs: searchJobsFresh } = + await import("../../../../src/adapters/goScraper.ts"); + await searchJobsFresh(validParams); + + expect(mocks.fetch).toHaveBeenCalledWith( + "http://custom-go:9999/scrape", + expect.anything(), + ); + + if (previousGoScraperUrl === undefined) { + delete process.env.GO_SCRAPER_URL; + } else { + process.env.GO_SCRAPER_URL = previousGoScraperUrl; + } + vi.resetModules(); + }); +}