From 866fc3953d9dbd50b7b75b5d35ebeb7600da6862 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Wed, 30 Sep 2026 22:19:37 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(PAV-128):=20corre=C3=A7=C3=A3o=20do=20?= =?UTF-8?q?coverage=20do=20sistema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/savedJobs.routes.test.ts | 109 ++ .../routes/searchJobs.routes.test.ts | 333 +++++ .../jobs/jobProfileMatch.service.test.ts | 114 ++ .../modules/jobs/searchJobs.service.test.ts | 746 +++++++--- .../applicationNotes.service.test.ts | 147 ++ .../unit/scripts/backfillUserPii.test.ts | 150 ++ backend/tests/unit/scripts/seed.test.ts | 72 + .../app/layouts/NotificationButton.test.tsx | 194 +++ .../tests/app/layouts/SidebarFooter.test.tsx | 27 + .../tests/app/layouts/TimeFilter.test.tsx | 17 + .../tests/modules/audit/AuditPage.test.tsx | 31 +- .../modules/dashboard/DashboardPage.test.tsx | 14 + .../components/PlatformChart.test.tsx | 23 + .../dashboard/hooks/dashboard-hooks.test.tsx | 78 +- .../observability/ObservabilityPage.test.tsx | 85 +- .../ObservabilityPanelCard.test.tsx | 141 +- .../permissions/PermissionsPage.test.tsx | 53 +- .../modules/scrapers/ScrapersPage.test.tsx | 44 +- .../scrapers/hooks/useScrapers.test.tsx | 153 ++- .../tests/modules/users/UsersPage.test.tsx | 77 ++ .../users/components/UserList.test.tsx | 28 + front_admin/vite.config.ts | 1 + .../unit/components/login/RigthSide.test.tsx | 4 +- .../tests/unit/new_dashboard/HelpTab.test.tsx | 41 + .../tests/unit/new_dashboard/page.test.tsx | 1218 +++++++++++------ .../tests/unit/new_dashboard/types.test.ts | 210 +++ .../tests/unit/new_dashboard/utils.test.ts | 97 +- 27 files changed, 3582 insertions(+), 625 deletions(-) create mode 100644 backend/tests/integration/routes/searchJobs.routes.test.ts create mode 100644 backend/tests/unit/modules/jobs/jobProfileMatch.service.test.ts create mode 100644 backend/tests/unit/modules/savedJobs/applicationNotes.service.test.ts create mode 100644 backend/tests/unit/scripts/backfillUserPii.test.ts create mode 100644 backend/tests/unit/scripts/seed.test.ts create mode 100644 front_admin/tests/app/layouts/NotificationButton.test.tsx create mode 100644 front_admin/tests/app/layouts/SidebarFooter.test.tsx create mode 100644 front_admin/tests/app/layouts/TimeFilter.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/HelpTab.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/types.test.ts diff --git a/backend/tests/integration/routes/savedJobs.routes.test.ts b/backend/tests/integration/routes/savedJobs.routes.test.ts index f3c68852..7a6d6d4f 100644 --- a/backend/tests/integration/routes/savedJobs.routes.test.ts +++ b/backend/tests/integration/routes/savedJobs.routes.test.ts @@ -13,6 +13,13 @@ const mockSavedJobsService = vi.hoisted(() => ({ delete: vi.fn(), })); +const mockApplicationNotesService = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + vi.mock("../../../src/modules/savedJobs/savedJobs.service", () => ({ SavedJobsService: class { constructor() { @@ -21,6 +28,14 @@ vi.mock("../../../src/modules/savedJobs/savedJobs.service", () => ({ }, })); +vi.mock("../../../src/modules/savedJobs/applicationNotes.service", () => ({ + ApplicationNotesService: class { + constructor() { + return mockApplicationNotesService as any; + } + }, +})); + // ── iron-session ────────────────────────────────────────────────────────────── // SavedJobsController chama getIronSession diretamente em cada action, // além do withSession aplicado globalmente no app.ts. @@ -41,6 +56,7 @@ const fixtureSession = { }; const fixtureJobId = "e4b095ff-6439-4112-b837-024a50f838b0"; +const fixtureNoteId = "d3a75656-203f-4c26-a647-2bbbe4d3b304"; const fixtureJob = { id: fixtureJobId, @@ -92,6 +108,15 @@ const fixtureEvents = [ }, ]; +const fixtureNote = { + id: fixtureNoteId, + userId: "user_abc", + savedJobId: fixtureJobId, + content: "Preparar exemplos para entrevista", + createdAt: new Date("2024-01-04").toISOString(), + updatedAt: new Date("2024-01-04").toISOString(), +}; + // ───────────────────────────────────────────────────────────────────────────── describe("Integration - SavedJobs Routes", () => { @@ -112,6 +137,13 @@ describe("Integration - SavedJobs Routes", () => { status: "applied", }); mockSavedJobsService.delete.mockResolvedValue(undefined); + mockApplicationNotesService.list.mockResolvedValue([fixtureNote]); + mockApplicationNotesService.create.mockResolvedValue(fixtureNote); + mockApplicationNotesService.update.mockResolvedValue({ + ...fixtureNote, + content: "Nota atualizada", + }); + mockApplicationNotesService.delete.mockResolvedValue(undefined); app = createJobsApiApp(); }); @@ -451,4 +483,81 @@ describe("Integration - SavedJobs Routes", () => { await request(app).delete(`${BASE}/${fixtureJobId}`).expect(500); }); }); + + describe("Application notes", () => { + it("lista notas da vaga no escopo do usuário autenticado", async () => { + const res = await request(app) + .get(`${BASE}/${fixtureJobId}/notes`) + .expect(200); + + expect(res.body).toEqual([fixtureNote]); + expect(mockApplicationNotesService.list).toHaveBeenCalledWith( + "user_abc", + fixtureJobId, + ); + }); + + it("cria nota com conteúdo trimado e retorna 201", async () => { + const res = await request(app) + .post(`${BASE}/${fixtureJobId}/notes`) + .send({ content: " Preparar exemplos para entrevista " }) + .expect(201); + + expect(res.body).toEqual(fixtureNote); + expect(mockApplicationNotesService.create).toHaveBeenCalledWith( + "user_abc", + fixtureJobId, + "Preparar exemplos para entrevista", + ); + }); + + it("rejeita conteúdo vazio antes de chamar o service", async () => { + await request(app) + .post(`${BASE}/${fixtureJobId}/notes`) + .send({ content: " " }) + .expect(400); + + expect(mockApplicationNotesService.create).not.toHaveBeenCalled(); + }); + + it("atualiza nota identificada pela vaga e pelo ID da nota", async () => { + const res = await request(app) + .patch(`${BASE}/${fixtureJobId}/notes/${fixtureNoteId}`) + .send({ content: "Nota atualizada" }) + .expect(200); + + expect(res.body.content).toBe("Nota atualizada"); + expect(mockApplicationNotesService.update).toHaveBeenCalledWith( + "user_abc", + fixtureJobId, + fixtureNoteId, + "Nota atualizada", + ); + }); + + it("retorna 404 ao atualizar nota inexistente", async () => { + mockApplicationNotesService.update.mockRejectedValueOnce( + AppError.notFound("Nota não encontrada"), + ); + + const res = await request(app) + .patch(`${BASE}/${fixtureJobId}/notes/${fixtureNoteId}`) + .send({ content: "Nota atualizada" }) + .expect(404); + + expect(res.body.message).toBe("Nota não encontrada"); + }); + + it("remove nota e retorna 204", async () => { + await request(app) + .delete(`${BASE}/${fixtureJobId}/notes/${fixtureNoteId}`) + .expect(204); + + expect(mockApplicationNotesService.delete).toHaveBeenCalledWith( + "user_abc", + fixtureJobId, + fixtureNoteId, + ); + }); + }); }); diff --git a/backend/tests/integration/routes/searchJobs.routes.test.ts b/backend/tests/integration/routes/searchJobs.routes.test.ts new file mode 100644 index 00000000..91bd7162 --- /dev/null +++ b/backend/tests/integration/routes/searchJobs.routes.test.ts @@ -0,0 +1,333 @@ +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const cacheMocks = vi.hoisted(() => ({ + cacheAbsoluteSMembers: vi.fn(), + cacheGetJobsByIds: vi.fn(), + cacheGetJobsByIdsDetailed: vi.fn(), + cacheRemoveJobIndexIds: vi.fn(), + cacheSearchJobIds: vi.fn(), + cacheSearchKeywords: vi.fn(), +})); + +const profileMocks = vi.hoisted(() => ({ + getUserById: vi.fn(), +})); + +vi.mock("../../../src/lib/cache", async (importOriginal) => ({ + ...(await importOriginal()), + ...cacheMocks, +})); + +vi.mock("iron-session", () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: "user-search" }), +})); + +vi.mock("../../../src/modules/users/users.service", () => ({ + UsersService: class { + getUserById = profileMocks.getUserById; + }, +})); + +import { createJobsApiApp } from "../../../src/app"; + +const baseJobs = [ + { + id: "joinville-go", + title: "Desenvolvedor Go Sênior", + company: "Acme", + location: "Joinville, Santa Catarina - Brasil", + description: "Vaga presencial CLT", + classification: { + primaryFamily: "Backend", + technologies: ["Go", "PostgreSQL"], + seniority: "Sênior", + }, + }, + { + id: "curitiba-go", + title: "Desenvolvedor Go Sênior", + company: "Acme", + location: "Curitiba, Paraná - Brasil", + description: "Vaga presencial CLT", + classification: { + primaryFamily: "Backend", + technologies: ["Go", "PostgreSQL"], + seniority: "Sênior", + }, + }, + { + id: "toronto-react", + title: "Frontend Engineer", + company: "Globex", + location: "Toronto, Canadá", + modality: "Hybrid", + description: "React, full time", + classification: { + primaryFamily: "Frontend", + technologies: ["React", "TypeScript"], + seniority: "Pleno", + }, + }, + { + id: "new-york-react", + title: "Frontend Engineer", + company: "Globex", + location: "New York, United States", + modality: "Remote", + description: "React, TypeScript, PJ", + classification: { + primaryFamily: "Frontend", + technologies: ["React", "TypeScript"], + seniority: "Pleno", + }, + }, +]; + +const app = createJobsApiApp(); +const searchUrl = "/api/v1/jobs/search"; + +function ids(jobs: Array<{ id?: string }>) { + return jobs.map((job) => job.id); +} + +function setStructuredJobs( + jobs: Array<{ id: string; [key: string]: unknown }> = baseJobs, +) { + cacheMocks.cacheSearchJobIds.mockResolvedValue(jobs.map((job) => job.id)); + cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); +} + +function setLegacyJobs( + jobs: Array<{ id: string; [key: string]: unknown }> = baseJobs, +) { + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(jobs.map((job) => job.id)); + cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); +} + +function setFallbackJobs(jobs = baseJobs) { + cacheMocks.cacheSearchJobIds.mockResolvedValue([]); + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(jobs.map((job) => job.id)); + cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); +} + +beforeEach(() => { + vi.clearAllMocks(); + profileMocks.getUserById.mockResolvedValue(undefined); + cacheMocks.cacheSearchJobIds.mockResolvedValue([]); + cacheMocks.cacheSearchKeywords.mockResolvedValue([]); + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue([]); + cacheMocks.cacheGetJobsByIds.mockResolvedValue([]); + cacheMocks.cacheGetJobsByIdsDetailed.mockImplementation( + async (jobIds: string[]) => ({ + jobs: baseJobs.filter((job) => jobIds.includes(job.id)), + missingIds: [], + }), + ); +}); + +describe("Integration - GET /jobs/search", () => { + it("atravessa rota, parser, filtro e service sem filtros", async () => { + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(baseJobs.map((job) => job.id)); + + const response = await request(app).get(searchUrl).expect(200); + + expect(ids(response.body.jobs)).toEqual(ids(baseJobs)); + expect(response.body).toMatchObject({ + total: baseJobs.length, + page: 1, + limit: 100, + totalPages: 1, + hasNext: false, + hasPrev: false, + source: "valkey_global_index", + }); + }); + + it("usa keywords no filtro legacy da busca", async () => { + cacheMocks.cacheSearchKeywords.mockResolvedValue(["joinville-go"]); + cacheMocks.cacheGetJobsByIds.mockResolvedValue([baseJobs[0]]); + + const response = await request(app) + .get(searchUrl) + .query({ keywords: "go,backend" }) + .expect(200); + + expect(ids(response.body.jobs)).toEqual(["joinville-go"]); + expect(cacheMocks.cacheSearchKeywords).toHaveBeenCalledWith([ + "go", + "backend", + ]); + }); + + it("retorna erro HTTP quando a busca de índices falha", async () => { + cacheMocks.cacheSearchJobIds.mockRejectedValueOnce(new Error("cache down")); + + const response = await request(app) + .get(searchUrl) + .query({ family: "Backend" }) + .expect(500); + + expect(response.body).toMatchObject({ + message: "Erro ao recuperar vagas em memória.", + error: "cache down", + }); + }); + + it("combina filtros estruturados e pós-filtra as candidatas do cache", async () => { + setStructuredJobs(); + + const response = await request(app) + .get(searchUrl) + .query({ technology: "Go", family: "Backend", country: "Brasil", state: "SC", city: "Joinville", contract: "clt" }) + .expect(200); + + expect(ids(response.body.jobs)).toEqual(["joinville-go"]); + expect(response.body.total).toBe(1); + expect(response.body.source).toContain("structured_indexes:verified"); + }); + + it.each([ + { query: { model: "remoto" }, expected: "new-york-react" }, + { query: { type: "hibrido" }, expected: "toronto-react" }, + { query: { model: "remoto", type: "remoto" }, expected: "new-york-react" }, + { query: { model: "remoto", type: "hibrido" }, expected: "new-york-react" }, + ])("aplica modalidade pelo parâmetro efetivo $query", async ({ query, expected }) => { + setStructuredJobs(); + + const response = await request(app).get(searchUrl).query(query).expect(200); + + expect(ids(response.body.jobs)).toEqual([expected]); + }); + + it("usa cache estruturado preenchido e valida a resposta final", async () => { + setStructuredJobs(); + + const response = await request(app) + .get(searchUrl) + .query({ family: "Backend" }) + .expect(200); + + expect(ids(response.body.jobs)).toEqual(["joinville-go", "curitiba-go"]); + expect(cacheMocks.cacheSearchJobIds).toHaveBeenCalledOnce(); + expect(response.body.source).toContain("structured_indexes:verified"); + }); + + it("respeita o limite máximo de 100 resultados por página", async () => { + const jobs = Array.from({ length: 105 }, (_, index) => ({ + id: `job-${index}`, + title: `Engineer ${index}`, + location: "Remote", + classification: { primaryFamily: "Backend" }, + })); + setStructuredJobs(jobs); + + const response = await request(app) + .get(searchUrl) + .query({ family: "Backend", page: "1", limit: "101" }) + .expect(200); + + expect(response.body.jobs).toHaveLength(100); + expect(response.body).toMatchObject({ + total: 105, + page: 1, + limit: 100, + totalPages: 2, + hasNext: true, + }); + }); + + it.each([ + { query: { continent: "America do Sul" }, expected: ["joinville-go", "curitiba-go"] }, + { query: { state: "SC" }, expected: ["joinville-go"] }, + { query: { city: "Joinville" }, expected: ["joinville-go"] }, + { + query: { continent: "America do Sul", state: "SC", city: "Joinville" }, + expected: ["joinville-go"], + }, + ])("preserva filtros de localização no fallback: $query", async ({ query, expected }) => { + setFallbackJobs(); + + const response = await request(app).get(searchUrl).query(query).expect(200); + + expect(ids(response.body.jobs)).toEqual(expected); + expect(response.body.source).toContain("legacy_post_filter_fallback"); + }); + + it("preserva paginação depois de filtrar no fallback", async () => { + setFallbackJobs(); + + const response = await request(app) + .get(searchUrl) + .query({ continent: "America do Sul", limit: "1", page: "2" }) + .expect(200); + + expect(ids(response.body.jobs)).toEqual(["curitiba-go"]); + expect(response.body).toMatchObject({ + total: 2, + page: 2, + limit: 1, + totalPages: 2, + hasNext: false, + hasPrev: true, + }); + }); + + it.each([ + { query: { page: "1", limit: "2" }, expected: ["joinville-go", "curitiba-go"], totalPages: 2 }, + { + query: { page: "0", limit: "0" }, + expected: ["joinville-go", "curitiba-go", "toronto-react", "new-york-react"], + totalPages: 1, + }, + { query: { page: "2", limit: "2" }, expected: ["toronto-react", "new-york-react"], totalPages: 2 }, + { query: { page: "9", limit: "2" }, expected: [], totalPages: 2 }, + ])("aplica limites e offsets na paginação $query", async ({ query, expected, totalPages }) => { + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(baseJobs.map((job) => job.id)); + + const response = await request(app).get(searchUrl).query(query).expect(200); + + expect(ids(response.body.jobs)).toEqual(expected); + expect(response.body.totalPages).toBe(totalPages); + }); + + it("calcula match real sem correspondência, parcial e completo e ordena pelo score", async () => { + const matchJobs = [ + { id: "no-match", title: "Product Manager", company: "Match", location: "Remote" }, + { id: "partial", title: "Backend Go", company: "Match", location: "Remote" }, + { id: "complete", title: "Backend Go and React", company: "Match", location: "Remote" }, + ]; + setLegacyJobs(matchJobs); + profileMocks.getUserById.mockResolvedValue({ + technologies: null, + technologyExperiences: [ + { name: "Go", years: 2 }, + { name: "React", years: 1 }, + ], + }); + + const response = await request(app) + .get(searchUrl) + .query({ company: "Match", matchSort: "desc" }) + .expect(200); + + expect(ids(response.body.jobs)).toEqual(["complete", "partial", "no-match"]); + expect(response.body.jobs).toMatchObject([ + { matchScore: 98, matchedTechnologies: ["Go", "React"] }, + { matchScore: 82, matchedTechnologies: ["Go"] }, + { matchScore: 45, matchedTechnologies: [] }, + ]); + }); + + it("não adiciona score quando não há dados de perfil", async () => { + setLegacyJobs([{ id: "job-no-profile", title: "Backend Go", company: "Acme" }]); + + const response = await request(app) + .get(searchUrl) + .query({ company: "Acme" }) + .expect(200); + + expect(response.body.jobs[0]).not.toHaveProperty("matchScore"); + expect(profileMocks.getUserById).toHaveBeenCalledWith("user-search"); + }); +}); diff --git a/backend/tests/unit/modules/jobs/jobProfileMatch.service.test.ts b/backend/tests/unit/modules/jobs/jobProfileMatch.service.test.ts new file mode 100644 index 00000000..2ce436b4 --- /dev/null +++ b/backend/tests/unit/modules/jobs/jobProfileMatch.service.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const profileMocks = vi.hoisted(() => ({ + getUserById: vi.fn(), + createHighMatchIfMissing: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/users.service", () => ({ + UsersService: class { + getUserById = profileMocks.getUserById; + }, +})); + +vi.mock("../../../../src/modules/notifications/notifications.service", () => ({ + NotificationsService: class { + createHighMatchIfMissing = profileMocks.createHighMatchIfMissing; + }, +})); + +vi.mock("../../../../src/logger", () => ({ + logWarn: profileMocks.logWarn, +})); + +import { JobProfileMatchService } from "../../../../src/modules/jobs/services/jobProfileMatch.service"; + +describe("JobProfileMatchService", () => { + let service: JobProfileMatchService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new JobProfileMatchService(); + profileMocks.getUserById.mockResolvedValue({ + technologies: null, + technologyExperiences: [{ name: "Go", years: 2 }], + }); + profileMocks.createHighMatchIfMissing.mockResolvedValue(undefined); + }); + + it("retorna perfil vazio sem consultar usuário quando não há userId", async () => { + await expect(service.getUserTechnologies()).resolves.toEqual([]); + expect(profileMocks.getUserById).not.toHaveBeenCalled(); + }); + + it("carrega tecnologias do perfil e captura falha de leitura", async () => { + await expect(service.getUserTechnologies("user-1")).resolves.toEqual([ + { name: "Go", years: 2 }, + ]); + expect(profileMocks.getUserById).toHaveBeenCalledWith("user-1"); + + profileMocks.getUserById.mockRejectedValueOnce(new Error("database down")); + await expect(service.getUserTechnologies("user-1")).resolves.toEqual([]); + expect(profileMocks.logWarn).toHaveBeenCalledWith( + "Não foi possível carregar perfil para cálculo de match", + expect.objectContaining({ userId: "user-1", error: "database down" }), + ); + }); + + it("preserva vagas sem score se tecnologias não existem", async () => { + const jobs = [{ id: "job-1", title: "Backend Developer" }]; + + await expect(service.enrich("user-1", jobs, [])).resolves.toBe(jobs); + expect(profileMocks.createHighMatchIfMissing).not.toHaveBeenCalled(); + }); + + it("calcula score real e cria notificação para match alto", async () => { + const [job] = await service.enrich( + "user-1", + [{ id: "job-1", title: "Go Backend Developer" }], + [{ name: "Go", years: 2 }], + ); + + expect(job).toMatchObject({ + matchScore: 94, + matchSource: "backend_profile", + matchedTechnologies: ["Go"], + }); + expect(profileMocks.createHighMatchIfMissing).toHaveBeenCalledWith( + "user-1", + job, + ); + }); + + it("suprime notificações quando caller pede apenas cálculo de score", async () => { + await service.enrich( + "user-1", + [{ id: "job-1", title: "Go Backend Developer" }], + [{ name: "Go", years: 2 }], + { notifyHighMatches: false }, + ); + + expect(profileMocks.createHighMatchIfMissing).not.toHaveBeenCalled(); + }); + + it("registra e absorve falha ao persistir notificação de match alto", async () => { + profileMocks.createHighMatchIfMissing.mockRejectedValueOnce( + new Error("notification store down"), + ); + + await service.enrich( + "user-1", + [{ id: "job-1", title: "Go Backend Developer" }], + [{ name: "Go", years: 2 }], + ); + + expect(profileMocks.logWarn).toHaveBeenCalledWith( + "Não foi possível registrar notificação de alto match", + expect.objectContaining({ + userId: "user-1", + error: "notification store down", + }), + ); + }); +}); diff --git a/backend/tests/unit/modules/jobs/searchJobs.service.test.ts b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts index f9f50da4..19f4bad6 100644 --- a/backend/tests/unit/modules/jobs/searchJobs.service.test.ts +++ b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts @@ -1,281 +1,629 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ +vi.mock("../../../../src/lib/cache", () => ({ cacheAbsoluteSMembers: vi.fn(), cacheGetJobsByIds: vi.fn(), cacheGetJobsByIdsDetailed: vi.fn(), cacheRemoveJobIndexIds: vi.fn(), cacheSearchJobIds: vi.fn(), cacheSearchKeywords: vi.fn(), - logWarn: vi.fn(), })); -vi.mock("../../../../src/lib/cache.js", () => ({ - cacheAbsoluteSMembers: mocks.cacheAbsoluteSMembers, - cacheGetJobsByIds: mocks.cacheGetJobsByIds, - cacheGetJobsByIdsDetailed: mocks.cacheGetJobsByIdsDetailed, - cacheRemoveJobIndexIds: mocks.cacheRemoveJobIndexIds, - cacheSearchJobIds: mocks.cacheSearchJobIds, - cacheSearchKeywords: mocks.cacheSearchKeywords, +vi.mock("../../../../src/lib/pagination", () => ({ + paginate: vi.fn(), + parsePagination: vi.fn(), })); -vi.mock("../../../../src/logger.js", () => ({ - logWarn: mocks.logWarn, - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +vi.mock("../../../../src/logger", () => ({ + logWarn: vi.fn(), })); -const profileMocks = vi.hoisted(() => ({ - technologies: [] as Array<{ name: string; years: number }>, +vi.mock("../../../../src/modules/jobs/filters/jobSearch.filter", () => ({ + filterJobs: vi.fn(), + sortJobsByMatch: vi.fn(), })); -vi.mock( - "../../../../src/modules/jobs/services/jobProfileMatch.service.js", - async () => { - const { scoreJobWithTechnologies } = await import( - "../../../../src/modules/jobs/services/jobMatch.service.js" - ); - - return { - JobProfileMatchService: class { - async getUserTechnologies() { - return profileMocks.technologies; - } - async enrich(_userId: unknown, jobs: any[], technologies: any[]) { - if (!technologies || technologies.length === 0) return jobs; - return jobs.map((job) => - scoreJobWithTechnologies(job, technologies), - ); - } - }, - }; - }, -); - -import { SearchJobsService } from "../../../../src/modules/jobs/services/searchJobs.service.js"; - -const INDEX_KEY = "scraper:jobs:index"; +vi.mock("../../../../src/modules/jobs/parsers/jobSearchQuery.parser", () => ({ + hasPostOnlyFilters: vi.fn(), + hasStructuredFilters: vi.fn(), + parseJobSearchQuery: vi.fn(), +})); -function makeIds(count: number, prefix = "id"): string[] { - return Array.from({ length: count }, (_, index) => `${prefix}-${index + 1}`); +import { + cacheAbsoluteSMembers, + cacheGetJobsByIds, + cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds, + cacheSearchJobIds, + cacheSearchKeywords, +} from "../../../../src/lib/cache"; +import { paginate, parsePagination } from "../../../../src/lib/pagination"; +import { logWarn } from "../../../../src/logger"; +import { + filterJobs, + sortJobsByMatch, +} from "../../../../src/modules/jobs/filters/jobSearch.filter"; +import { + hasPostOnlyFilters, + hasStructuredFilters, + parseJobSearchQuery, +} from "../../../../src/modules/jobs/parsers/jobSearchQuery.parser"; +import { JobProfileMatchService } from "../../../../src/modules/jobs/services/jobProfileMatch.service"; +import { + SearchJobsService, + searchJobsService, +} from "../../../../src/modules/jobs/services/searchJobs.service"; +import type { ParsedJobSearchQuery } from "../../../../src/modules/jobs/types/jobSearch.types"; + +const mockCacheAbsoluteSMembers = vi.mocked(cacheAbsoluteSMembers); +const mockCacheGetJobsByIds = vi.mocked(cacheGetJobsByIds); +const mockCacheGetJobsByIdsDetailed = vi.mocked(cacheGetJobsByIdsDetailed); +const mockCacheRemoveJobIndexIds = vi.mocked(cacheRemoveJobIndexIds); +const mockCacheSearchJobIds = vi.mocked(cacheSearchJobIds); +const mockCacheSearchKeywords = vi.mocked(cacheSearchKeywords); +const mockPaginate = vi.mocked(paginate); +const mockParsePagination = vi.mocked(parsePagination); +const mockLogWarn = vi.mocked(logWarn); +const mockFilterJobs = vi.mocked(filterJobs); +const mockSortJobsByMatch = vi.mocked(sortJobsByMatch); +const mockHasPostOnlyFilters = vi.mocked(hasPostOnlyFilters); +const mockHasStructuredFilters = vi.mocked(hasStructuredFilters); +const mockParseJobSearchQuery = vi.mocked(parseJobSearchQuery); + +const defaultPagination = { page: 1, limit: 10 }; + +function emptyFilters( + overrides: Partial = {}, +): ParsedJobSearchQuery { + return { + keywords: [], + family: [], + technology: [], + company: [], + seniority: "", + level: "", + location: "", + continent: "", + country: "", + state: "", + city: "", + type: [], + contract: "", + matchSort: null, + ...overrides, + }; } -function job(id: string) { - return { id, title: `Vaga ${id}`, company: "ACME" }; +function makeEnrichResult(jobs: unknown[]) { + return jobs.map((job) => ({ ...(job as object), matchScore: 50 })); } -function hydrateFrom(liveIds: Set) { - return async (ids: string[]) => ({ - jobs: ids.filter((id) => liveIds.has(id)).map(job), - missingIds: ids.filter((id) => !liveIds.has(id)), - }); +function buildProfileMatchService() { + const service = { + getUserTechnologies: vi.fn().mockResolvedValue([]), + enrich: vi + .fn() + .mockImplementation(async (_u, jobs: unknown[]) => + makeEnrichResult(jobs), + ), + }; + return service as unknown as JobProfileMatchService & typeof service; } -describe("SearchJobsService — índice global do Valkey", () => { - let service: SearchJobsService; +beforeEach(() => { + vi.clearAllMocks(); + mockParsePagination.mockReturnValue(defaultPagination); + mockHasStructuredFilters.mockReturnValue(false); + mockHasPostOnlyFilters.mockReturnValue(false); + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockFilterJobs.mockImplementation((jobs: unknown[]) => jobs); + mockSortJobsByMatch.mockImplementation((jobs: unknown[]) => jobs); + mockPaginate.mockImplementation((jobs: unknown[]) => ({ + data: jobs, + pagination: { + total: jobs.length, + page: 1, + limit: 10, + totalPages: 1, + hasNext: false, + hasPrev: false, + }, + })); +}); + +describe("SearchJobsService.execute - sem filtros, sem post-only, sem matchSort", () => { + it("resolve via índice global, ordena por perfil e retorna profile_ranked", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b", "c"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }, { id: "b" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockResolvedValueOnce(["a"]); - beforeEach(() => { - vi.clearAllMocks(); - mocks.cacheRemoveJobIndexIds.mockResolvedValue(0); - mocks.cacheSearchJobIds.mockResolvedValue([]); - mocks.cacheSearchKeywords.mockResolvedValue([]); - profileMocks.technologies = []; - service = new SearchJobsService(); + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(mockCacheAbsoluteSMembers).toHaveBeenCalledWith( + "scraper:jobs:index", + ); + expect(mockCacheSearchKeywords).toHaveBeenCalledWith(["react"]); + expect(result.source).toBe("valkey_global_index:profile_ranked"); + expect(mockSortJobsByMatch).toHaveBeenCalled(); }); - it("retorna a primeira página preenchida quando todos os IDs têm documento", async () => { - const ids = makeIds(120); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set(ids)), + it("resolve via keywords e mantém source do legacy quando sem match", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ keywords: ["react"] }), ); + mockCacheSearchKeywords.mockImplementation(async (keywords: string[]) => { + if (keywords[0] === "react") return ["x"]; + return []; + }); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "x" }], + missingIds: [], + }); - const result = await service.execute({ query: { page: "1", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(mocks.cacheAbsoluteSMembers).toHaveBeenCalledWith(INDEX_KEY); - expect(result.source).toBe("valkey_global_index"); - expect(result.total).toBe(120); - expect(result.page).toBe(1); - expect(result.limit).toBe(50); - expect(result.totalPages).toBe(3); - expect(result.hasNext).toBe(true); - expect(result.hasPrev).toBe(false); - expect(result.jobs).toHaveLength(50); - expect(result.jobs[0]).toEqual(job("id-1")); - expect(mocks.cacheRemoveJobIndexIds).not.toHaveBeenCalled(); + expect(mockCacheSearchKeywords).toHaveBeenNthCalledWith(1, ["react"]); + expect(result.source).toBe("valkey_filtered_by_keywords:react"); }); - it("preenche a página avançando no índice quando a primeira fatia só tem IDs órfãos", async () => { - const ids = makeIds(200); - // Os 50 primeiros IDs do índice estão órfãos (documentos expirados). - const liveIds = new Set(ids.slice(50)); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation(hydrateFrom(liveIds)); + it("retorna source simples quando matchedIds = 0", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); - const result = await service.execute({ query: { page: "1", limit: "50" } }); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockResolvedValueOnce([]); - expect(result.jobs).toHaveLength(50); - expect(result.jobs[0]).toEqual(job("id-51")); - // total desconta os órfãos já identificados, em vez de anunciar 200. - expect(result.total).toBe(150); - expect(result.totalPages).toBe(3); - expect(result.hasNext).toBe(true); + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).toBe("valkey_global_index"); + expect(mockSortJobsByMatch).not.toHaveBeenCalled(); }); +}); - it("remove do índice global os IDs sem documento (auto-reparo)", async () => { - const ids = makeIds(100); - const orphans = ["id-1", "id-2", "id-3"]; - const liveIds = new Set(ids.filter((id) => !orphans.includes(id))); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation(hydrateFrom(liveIds)); +describe("SearchJobsService.execute - legacyResolveIds com keywords", () => { + it("usa cacheSearchKeywords quando keywords presentes", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ keywords: ["node", "js"] }), + ); + mockCacheSearchKeywords.mockResolvedValueOnce(["k1", "k2"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "k1" }, { id: "k2" }], + missingIds: [], + }); - const result = await service.execute({ query: { page: "1", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(mocks.cacheRemoveJobIndexIds).toHaveBeenCalledWith(orphans); - expect(result.jobs).toHaveLength(50); - expect(result.total).toBe(97); + expect(result.source).toBe("valkey_filtered_by_keywords:node+js"); }); +}); - it("não falha a requisição quando o auto-reparo do índice falha", async () => { - const ids = makeIds(60); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set(ids.slice(1))), +describe("SearchJobsService.execute - hasFilters", () => { + it("usa cacheSearchJobIds e retorna verified quando há resultados indexados", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockHasStructuredFilters.mockReturnValue(true); + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ + keywords: ["react"], + family: ["front"], + technology: ["react"], + seniority: "sr", + level: "senior", + location: "BR", + continent: "SA", + country: "BR", + state: "SP", + city: "SP", + type: ["remote"], + contract: "clt", + }), ); - mocks.cacheRemoveJobIndexIds.mockRejectedValue(new Error("valkey down")); + mockCacheSearchJobIds.mockResolvedValueOnce(["id1", "id2"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "id1" }, { id: "id2" }]); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(mockCacheSearchJobIds).toHaveBeenCalledWith({ + keywords: ["react"], + family: ["front"], + technology: ["react"], + seniority: "sr", + level: "senior", + location: "BR", + continent: "SA", + country: "BR", + state: "SP", + city: "SP", + type: ["remote"], + model: ["remote"], + contract: "clt", + }); + expect(result.source).toContain("structured_indexes:verified"); + }); + + it("cai no fallback quando cacheSearchJobIds retorna vazio", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockHasStructuredFilters.mockReturnValue(true); + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ keywords: ["react"] }), + ); + mockCacheSearchJobIds.mockResolvedValueOnce([]); + mockCacheSearchKeywords.mockResolvedValueOnce(["legacy-id"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "legacy-id" }]); - const result = await service.execute({ query: { page: "1", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(result.jobs).toHaveLength(50); - expect(mocks.logWarn).toHaveBeenCalled(); + expect(result.source).toContain("legacy_post_filter_fallback"); + expect(mockCacheSearchKeywords).toHaveBeenCalledWith(["react"]); }); +}); + +describe("SearchJobsService.execute - hasPostOnlyFilters", () => { + it("aplica filterJobs e retorna post_filter", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); - it("retorna página vazia e coerente quando todos os IDs do índice estão órfãos", async () => { - const ids = makeIds(200); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set()), + mockHasStructuredFilters.mockReturnValue(false); + mockHasPostOnlyFilters.mockReturnValue(true); + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ matchSort: "desc" }), ); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "a" }, { id: "b" }]); + mockFilterJobs.mockReturnValueOnce([{ id: "a" }]); - const result = await service.execute({ query: { page: "1", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(result.jobs).toEqual([]); - expect(result.total).toBe(0); - expect(result.totalPages).toBe(0); - expect(result.hasNext).toBe(false); + expect(mockFilterJobs).toHaveBeenCalled(); + expect(result.source).toContain("post_filter"); + expect(mockSortJobsByMatch).toHaveBeenCalled(); }); +}); - it("limita o esforço de hidratação por requisição (não varre o índice inteiro)", async () => { - const ids = makeIds(100_000); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set()), +describe("SearchJobsService.execute - matchSort sem hasFilters", () => { + it("enriquece, ordena globalmente, pagina e re-enriquece a página", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ matchSort: "desc" }), ); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "a" }, { id: "b" }]); + mockSortJobsByMatch.mockReturnValueOnce([{ id: "b" }, { id: "a" }]); + mockPaginate.mockReturnValueOnce({ + data: [{ id: "b" }], + pagination: { + total: 2, + page: 1, + limit: 1, + totalPages: 2, + hasNext: true, + hasPrev: false, + }, + }); - await service.execute({ query: { page: "1", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(mocks.cacheGetJobsByIdsDetailed).toHaveBeenCalledTimes(10); + expect(profileService.enrich).toHaveBeenCalledTimes(2); + expect(result.source).toContain("match_sorted_desc"); + expect(result.hasNext).toBe(true); }); +}); + +describe("SearchJobsService - paginateFilteredJobs com matchSort", () => { + it("enriquece todos, ordena, pagina e re-enriquece página", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); - it("pagina corretamente a última página parcial", async () => { - const ids = makeIds(120); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set(ids)), + mockHasStructuredFilters.mockReturnValue(true); + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ matchSort: "asc", keywords: ["x"] }), ); + mockCacheSearchJobIds.mockResolvedValueOnce(["1", "2"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "1" }, { id: "2" }]); + mockFilterJobs.mockReturnValueOnce([{ id: "1" }, { id: "2" }]); + mockSortJobsByMatch.mockReturnValueOnce([{ id: "1" }, { id: "2" }]); + mockPaginate.mockReturnValueOnce({ + data: [{ id: "1" }], + pagination: { + total: 2, + page: 1, + limit: 1, + totalPages: 2, + hasNext: true, + hasPrev: false, + }, + }); - const result = await service.execute({ query: { page: "3", limit: "50" } }); + const result = await svc.execute({ userId: "u1", query: {} }); - expect(result.jobs).toHaveLength(20); - expect(result.jobs[0]).toEqual(job("id-101")); - expect(result.page).toBe(3); - expect(result.hasNext).toBe(false); - expect(result.hasPrev).toBe(true); + expect(profileService.enrich).toHaveBeenCalledTimes(2); + expect(result.total).toBe(2); }); +}); - it("retorna jobs vazio quando a página solicitada está além do índice", async () => { - const ids = makeIds(10); - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set(ids)), - ); +describe("hydrateIndexPage - comportamento", () => { + it("remove órfãos e calcula meta corretamente", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b", "c", "d"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }, { id: "b" }], + missingIds: ["c"], + }); - const result = await service.execute({ query: { page: "9", limit: "50" } }); + await svc.execute({ userId: "u1", query: {} }); - expect(result.jobs).toEqual([]); - expect(result.total).toBe(10); - expect(result.hasNext).toBe(false); - expect(result.hasPrev).toBe(true); + expect(mockCacheRemoveJobIndexIds).toHaveBeenCalledWith(["c"]); }); - it("mantém o caminho de filtros estruturados usando os índices do Valkey", async () => { - mocks.cacheSearchJobIds.mockResolvedValue(["id-1", "id-2"]); - mocks.cacheGetJobsByIds.mockResolvedValue([ - { id: "id-1", title: "Dev Node", company: "ACME", location: "Brasil" }, - { id: "id-2", title: "Dev Node", company: "Globo", location: "Brasil" }, - ]); + it("abre múltiplas janelas de hidratação quando faltam jobs", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); - const result = await service.execute({ query: { country: "Brasil" } }); + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockParsePagination.mockReturnValue({ page: 1, limit: 1 }); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIdsDetailed + .mockResolvedValueOnce({ jobs: [], missingIds: ["a"] }) + .mockResolvedValueOnce({ + jobs: [{ id: "b" }], + missingIds: [], + }); - expect(mocks.cacheAbsoluteSMembers).not.toHaveBeenCalled(); - expect(result.source).toContain("structured_indexes"); - expect(result.jobs).toHaveLength(2); - expect(result.total).toBe(2); + await svc.execute({ userId: "u1", query: {} }); + + expect(mockCacheGetJobsByIdsDetailed).toHaveBeenCalledTimes(2); }); - it("filtra por empresa mesmo sem índice dedicado, mantendo total coerente", async () => { - const ids = ["id-1", "id-2", "id-3"]; - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIds.mockResolvedValue([ - { id: "id-1", title: "Dev Node", company: "ACME Tecnologia" }, - { id: "id-2", title: "Dev Node", company: "Globo" }, - { id: "id-3", title: "Dev Go", company: "acme labs" }, - ]); + it("não remove órfãos quando não há missingIds", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: [], + }); - const result = await service.execute({ query: { company: "acme" } }); + await svc.execute({ userId: "u1", query: {} }); - expect(result.jobs).toHaveLength(2); - expect(result.total).toBe(2); - expect(result.totalPages).toBe(1); - expect(result.source).toContain("post_filter"); + expect(mockCacheRemoveJobIndexIds).not.toHaveBeenCalled(); }); - it("prioriza vagas compatíveis com as tecnologias do perfil do candidato", async () => { - profileMocks.technologies = [{ name: "Go", years: 4 }]; - const ids = ["sem-relacao-1", "sem-relacao-2", "compativel-1"]; - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - // Índice invertido do scraper devolve a vaga compatível com o perfil. - mocks.cacheSearchKeywords.mockResolvedValue(["compativel-1"]); - mocks.cacheGetJobsByIdsDetailed.mockImplementation(async (pageIds: string[]) => ({ - jobs: pageIds.map((id) => ({ - id, - title: id === "compativel-1" ? "Desenvolvedor Go" : "Analista Contábil", - company: "ACME", - })), + it("logWarn quando cacheRemoveJobIndexIds falha", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: ["b"], + }); + mockCacheRemoveJobIndexIds.mockRejectedValueOnce(new Error("boom")); + + await svc.execute({ userId: "u1", query: {} }); + + expect(mockLogWarn).toHaveBeenCalled(); + }); + + it("para ao atingir MAX_HYDRATION_WINDOWS mesmo faltando jobs", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + const ids = Array.from({ length: 20 }, (_, i) => `id${i}`); + mockParseJobSearchQuery.mockReturnValue(emptyFilters()); + mockCacheAbsoluteSMembers.mockResolvedValueOnce(ids); + mockCacheGetJobsByIdsDetailed.mockResolvedValue({ + jobs: [], missingIds: [], - })); + }); + mockParsePagination.mockReturnValue({ page: 1, limit: 1 }); + + await svc.execute({ userId: "u1", query: {} }); + + expect(mockCacheGetJobsByIdsDetailed).toHaveBeenCalledTimes(10); + }); +}); - const result = await service.execute({ - query: { page: "1", limit: "50" }, - userId: "user-1", +describe("orderIdsByProfileRelevance", () => { + it("retorna ids originais quando não há tecnologias", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi.fn().mockResolvedValue([]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }, { id: "b" }], + missingIds: [], }); - expect(mocks.cacheSearchKeywords).toHaveBeenCalledWith(["Go"]); - expect((result.jobs[0] as { id: string }).id).toBe("compativel-1"); - expect(result.source).toContain("profile_ranked"); - expect(result.total).toBe(3); + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).not.toContain("profile_ranked"); }); - it("mantém a ordem do índice quando o perfil não tem tecnologias", async () => { - const ids = ["id-1", "id-2"]; - mocks.cacheAbsoluteSMembers.mockResolvedValue(ids); - mocks.cacheGetJobsByIdsDetailed.mockImplementation( - hydrateFrom(new Set(ids)), - ); + it("ignora tecnologias sem nome", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: " " }, { name: undefined }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: [], + }); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).not.toContain("profile_ranked"); + }); + + it("logWarn e retorna ids originais quando cacheSearchKeywords falha", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockRejectedValueOnce(new Error("cache down")); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(mockLogWarn).toHaveBeenCalled(); + expect(result.source).toBe("valkey_global_index"); + }); + + it("retorna ids originais quando profileIds vazio", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockResolvedValueOnce([]); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).toBe("valkey_global_index"); + }); + + it("retorna ids originais quando nenhum id bate com o perfil", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "a" }, { id: "b" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockResolvedValueOnce(["zzz"]); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).toBe("valkey_global_index"); + }); + + it("prioriza ids relevantes e usa profile_ranked", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); + + mockCacheAbsoluteSMembers.mockResolvedValueOnce(["a", "b", "c"]); + mockCacheGetJobsByIdsDetailed.mockResolvedValueOnce({ + jobs: [{ id: "c" }, { id: "a" }, { id: "b" }], + missingIds: [], + }); + mockCacheSearchKeywords.mockResolvedValueOnce(["c"]); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result.source).toBe("valkey_global_index:profile_ranked"); + expect(mockSortJobsByMatch).toHaveBeenCalled(); + }); + + it("retorna matchedIds=0 quando ids está vazio", async () => { + const profileService = buildProfileMatchService(); + profileService.getUserTechnologies = vi + .fn() + .mockResolvedValue([{ name: "react" }]); + const svc = new SearchJobsService(profileService); - const result = await service.execute({ query: {}, userId: "user-1" }); + mockCacheAbsoluteSMembers.mockResolvedValueOnce([]); + mockParsePagination.mockReturnValue({ page: 1, limit: 10 }); + + const result = await svc.execute({ userId: "u1", query: {} }); - expect(mocks.cacheSearchKeywords).not.toHaveBeenCalled(); expect(result.source).toBe("valkey_global_index"); - expect((result.jobs[0] as { id: string }).id).toBe("id-1"); + expect(mockCacheSearchKeywords).not.toHaveBeenCalled(); + }); +}); + +describe("searchJobsService (singleton)", () => { + it("é uma instância de SearchJobsService", () => { + expect(searchJobsService).toBeInstanceOf(SearchJobsService); + }); +}); + +describe("toSearchResult - shape", () => { + it("mapeia pagination para o resultado", async () => { + const profileService = buildProfileMatchService(); + const svc = new SearchJobsService(profileService); + + mockHasStructuredFilters.mockReturnValue(true); + mockParseJobSearchQuery.mockReturnValue( + emptyFilters({ family: ["backend"] }), + ); + mockCacheSearchJobIds.mockResolvedValueOnce(["a"]); + mockCacheGetJobsByIds.mockResolvedValueOnce([{ id: "a" }]); + mockPaginate.mockReturnValueOnce({ + data: [{ id: "a" }], + pagination: { + total: 5, + page: 2, + limit: 3, + totalPages: 2, + hasNext: false, + hasPrev: true, + }, + }); + + const result = await svc.execute({ userId: "u1", query: {} }); + + expect(result).toMatchObject({ + total: 5, + page: 2, + limit: 3, + totalPages: 2, + hasNext: false, + hasPrev: true, + }); }); }); diff --git a/backend/tests/unit/modules/savedJobs/applicationNotes.service.test.ts b/backend/tests/unit/modules/savedJobs/applicationNotes.service.test.ts new file mode 100644 index 00000000..5cc240f4 --- /dev/null +++ b/backend/tests/unit/modules/savedJobs/applicationNotes.service.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const drizzleMocks = vi.hoisted(() => ({ + and: vi.fn(), + asc: vi.fn(), + eq: vi.fn(), +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + and: drizzleMocks.and, + asc: drizzleMocks.asc, + eq: drizzleMocks.eq, + }; +}); + +import { ApplicationNotesService } from "../../../../src/modules/savedJobs/applicationNotes.service"; + +function makeMockTx() { + return { + query: { + applicationNotes: { + findMany: vi.fn(), + }, + }, + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +function allowJobOwner(tx: ReturnType) { + const limit = vi.fn().mockResolvedValue([{ id: "job-1" }]); + const where = vi.fn().mockReturnValue({ limit }); + tx.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ where }), + }); + return { where, limit }; +} + +describe("ApplicationNotesService", () => { + let tx: ReturnType; + let service: ApplicationNotesService; + + beforeEach(() => { + vi.clearAllMocks(); + tx = makeMockTx(); + service = new ApplicationNotesService(tx as never); + drizzleMocks.and.mockImplementation((...conditions) => conditions); + drizzleMocks.asc.mockImplementation((column) => ({ direction: "asc", column })); + drizzleMocks.eq.mockImplementation((column, value) => ({ column, value })); + }); + + it("lista notas somente após confirmar que a vaga pertence ao usuário", async () => { + const note = { id: "note-1", savedJobId: "job-1", userId: "user-1", content: "Entrevista" }; + const ownerQuery = allowJobOwner(tx); + tx.query.applicationNotes.findMany.mockResolvedValue([note]); + + const result = await service.list("user-1", "job-1"); + + expect(result).toEqual([note]); + expect(ownerQuery.where).toHaveBeenCalledOnce(); + expect(tx.query.applicationNotes.findMany).toHaveBeenCalledOnce(); + const query = tx.query.applicationNotes.findMany.mock.calls[0][0]; + expect(query.where({ userId: "notes.userId", savedJobId: "notes.savedJobId" }, drizzleMocks)).toEqual([ + { column: "notes.userId", value: "user-1" }, + { column: "notes.savedJobId", value: "job-1" }, + ]); + }); + + it("retorna NOT_FOUND e não lista nota quando a vaga não pertence ao usuário", async () => { + const limit = vi.fn().mockResolvedValue([]); + tx.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit }), + }), + }); + + await expect(service.list("other-user", "job-1")).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Vaga não encontrada", + }); + expect(tx.query.applicationNotes.findMany).not.toHaveBeenCalled(); + }); + + it("cria nota com usuário, vaga e conteúdo validados", async () => { + const note = { id: "note-1", savedJobId: "job-1", userId: "user-1", content: "Entrevista" }; + allowJobOwner(tx); + const returning = vi.fn().mockResolvedValue([note]); + const values = vi.fn().mockReturnValue({ returning }); + tx.insert.mockReturnValue({ values }); + + await expect(service.create("user-1", "job-1", "Entrevista")).resolves.toEqual(note); + + expect(values).toHaveBeenCalledWith({ + userId: "user-1", + savedJobId: "job-1", + content: "Entrevista", + }); + }); + + it("atualiza uma nota somente pelo ID, vaga e usuário", async () => { + const note = { id: "note-1", savedJobId: "job-1", userId: "user-1", content: "Atualizada" }; + const returning = vi.fn().mockResolvedValue([note]); + const where = vi.fn().mockReturnValue({ returning }); + const set = vi.fn().mockReturnValue({ where }); + tx.update.mockReturnValue({ set }); + + await expect(service.update("user-1", "job-1", "note-1", "Atualizada")).resolves.toEqual(note); + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ content: "Atualizada" })); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "note-1"); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "job-1"); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "user-1"); + }); + + it("retorna NOT_FOUND ao atualizar nota fora do escopo do usuário", async () => { + const returning = vi.fn().mockResolvedValue([]); + const where = vi.fn().mockReturnValue({ returning }); + tx.update.mockReturnValue({ set: vi.fn().mockReturnValue({ where }) }); + + await expect(service.update("other-user", "job-1", "note-1", "Texto")).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Nota não encontrada", + }); + }); + + it("remove nota no escopo correto e sinaliza nota inexistente", async () => { + const returning = vi.fn().mockResolvedValue([{ id: "note-1" }]); + const where = vi.fn().mockReturnValue({ returning }); + tx.delete.mockReturnValue({ where }); + + await expect(service.delete("user-1", "job-1", "note-1")).resolves.toBeUndefined(); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "note-1"); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "job-1"); + expect(drizzleMocks.eq).toHaveBeenCalledWith(expect.anything(), "user-1"); + + returning.mockResolvedValueOnce([]); + await expect(service.delete("other-user", "job-1", "note-1")).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Nota não encontrada", + }); + }); +}); diff --git a/backend/tests/unit/scripts/backfillUserPii.test.ts b/backend/tests/unit/scripts/backfillUserPii.test.ts new file mode 100644 index 00000000..10ba4b14 --- /dev/null +++ b/backend/tests/unit/scripts/backfillUserPii.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const databaseMocks = vi.hoisted(() => ({ + credentialsRows: [] as Array>, + update: vi.fn(), + usersRows: [] as Array>, + select: vi.fn(), + selectionIndex: 0, +})); + +vi.mock("../../../src/db/client", () => ({ + db: { + select: databaseMocks.select, + update: databaseMocks.update, + }, +})); + +vi.mock("../../../src/lib/security/encryption", () => ({ + decryptText: vi.fn((value: string) => value.replace("encrypted:", "")), + encryptText: vi.fn((value: string) => `encrypted:${value}`), + isEncryptedPayload: vi.fn((value: string) => value.startsWith("encrypted:")), +})); + +vi.mock("../../../src/lib/security/normalization", () => ({ + normalizeCpf: vi.fn((value: string) => value.replace(/\D/g, "")), + normalizeEmail: vi.fn((value: string) => value.trim().toLowerCase()), +})); + +vi.mock("../../../src/lib/security/piiPayload", () => ({ + protectCpf: vi.fn((value: string) => ({ + cpfEncrypted: `encrypted:${value.replace(/\D/g, "")}`, + cpfHash: `hash:${value.replace(/\D/g, "")}`, + })), + protectEmail: vi.fn((value: string) => ({ + emailEncrypted: `encrypted:${value.trim().toLowerCase()}`, + emailHash: `hash:${value.trim().toLowerCase()}`, + })), + protectNullableText: vi.fn((value: string) => `encrypted:${value.trim()}`), +})); + +vi.mock("../../../src/lib/security/searchableHash", () => ({ + generateSearchableHash: vi.fn((value: string) => `hash:${value}`), +})); + +const originalArgv = [...process.argv]; + +function configureDatabase() { + databaseMocks.selectionIndex = 0; + databaseMocks.select.mockImplementation(() => ({ + from: vi.fn(() => { + const rows = databaseMocks.selectionIndex++ === 0 + ? databaseMocks.usersRows + : databaseMocks.credentialsRows; + return Promise.resolve(rows); + }), + })); + databaseMocks.update.mockImplementation(() => ({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + })); +} + +describe("backfillUserPii script", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + process.argv = originalArgv.filter((argument) => argument !== "--write"); + databaseMocks.usersRows = [ + { + id: "user-1", + email: "Person@Example.com", + emailEncrypted: null, + emailHash: null, + firstName: "Ada", + firstNameEncrypted: null, + lastName: null, + lastNameEncrypted: null, + displayName: null, + displayNameEncrypted: null, + avatarUrl: null, + avatarUrlEncrypted: null, + phone: null, + phoneEncrypted: null, + cpf: "123.456.789-00", + cpfEncrypted: null, + cpfHash: null, + technologies: ["Go"], + technologiesEncrypted: null, + level: "Senior", + levelEncrypted: null, + }, + ]; + databaseMocks.credentialsRows = [ + { id: "credential-1", email: " PERSON@EXAMPLE.COM ", emailHash: null }, + ]; + configureDatabase(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + process.argv = [...originalArgv]; + vi.restoreAllMocks(); + }); + + it("reports pending records in dry-run without persisting changes", async () => { + await import("../../../src/scripts/backfillUserPii"); + + await vi.waitFor(() => expect(console.log).toHaveBeenCalledOnce()); + + const report = JSON.parse(vi.mocked(console.log).mock.calls[0][0] as string); + expect(report).toEqual({ + mode: "dry-run", + users: { scanned: 1, pending: 1 }, + credentials: { scanned: 1, pending: 1 }, + }); + expect(databaseMocks.update).not.toHaveBeenCalled(); + }); + + it("writes encrypted user fields and normalized credential email with --write", async () => { + process.argv = [...originalArgv, "--write"]; + + await import("../../../src/scripts/backfillUserPii"); + + await vi.waitFor(() => expect(console.log).toHaveBeenCalledOnce()); + + expect(databaseMocks.update).toHaveBeenCalledTimes(2); + const userValues = databaseMocks.update.mock.results[0].value.set.mock.calls[0][0]; + const credentialValues = databaseMocks.update.mock.results[1].value.set.mock.calls[0][0]; + expect(userValues).toMatchObject({ + email: null, + emailEncrypted: "encrypted:person@example.com", + emailHash: "hash:person@example.com", + firstName: null, + firstNameEncrypted: "encrypted:Ada", + cpf: null, + cpfEncrypted: "encrypted:12345678900", + cpfHash: "hash:12345678900", + technologies: null, + technologiesEncrypted: 'encrypted:["Go"]', + level: null, + levelEncrypted: "encrypted:Senior", + }); + expect(credentialValues).toMatchObject({ + email: "encrypted:person@example.com", + emailHash: "hash:person@example.com", + }); + }); +}); diff --git a/backend/tests/unit/scripts/seed.test.ts b/backend/tests/unit/scripts/seed.test.ts new file mode 100644 index 00000000..5ccf7746 --- /dev/null +++ b/backend/tests/unit/scripts/seed.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const seedMocks = vi.hoisted(() => ({ + applicationEventsFindFirst: vi.fn(), + credentialsFindFirst: vi.fn(), + poolEnd: vi.fn(), + savedJobsFindFirst: vi.fn(), + seedCatalogJobs: vi.fn(), + update: vi.fn(), + insert: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock("../../../src/db/client", () => ({ + db: { + query: { + applicationEvents: { findFirst: seedMocks.applicationEventsFindFirst }, + credentials: { findFirst: seedMocks.credentialsFindFirst }, + savedJobs: { findFirst: seedMocks.savedJobsFindFirst }, + }, + update: seedMocks.update, + insert: seedMocks.insert, + transaction: seedMocks.transaction, + }, + pool: { end: seedMocks.poolEnd }, +})); + +vi.mock("../../../src/scripts/seedCatalogJobs", () => ({ + SEED_DEV_TECHNOLOGIES: [{ name: "Go", years: 3 }], + seedCatalogJobs: seedMocks.seedCatalogJobs, +})); + +const originalArgv = [...process.argv]; + +describe("seed script", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + process.argv = [...originalArgv]; + seedMocks.credentialsFindFirst.mockResolvedValue({ userId: "dev-user" }); + seedMocks.savedJobsFindFirst + .mockResolvedValueOnce({ id: "job-1" }) + .mockResolvedValueOnce({ id: "job-2" }) + .mockResolvedValueOnce({ id: "job-3" }); + seedMocks.applicationEventsFindFirst + .mockResolvedValueOnce({ id: "event-1" }) + .mockResolvedValueOnce({ id: "event-2" }); + seedMocks.poolEnd.mockResolvedValue(undefined); + seedMocks.seedCatalogJobs.mockResolvedValue(undefined); + seedMocks.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + it("preserves existing records while refreshing profile and catalog data", async () => { + await import("../../../src/scripts/seed"); + + await vi.waitFor(() => expect(seedMocks.poolEnd).toHaveBeenCalledOnce()); + + expect(seedMocks.credentialsFindFirst).toHaveBeenCalledTimes(2); + expect(seedMocks.update).toHaveBeenCalledOnce(); + expect(seedMocks.savedJobsFindFirst).toHaveBeenCalledTimes(3); + expect(seedMocks.applicationEventsFindFirst).toHaveBeenCalledTimes(2); + expect(seedMocks.insert).not.toHaveBeenCalled(); + expect(seedMocks.transaction).not.toHaveBeenCalled(); + expect(seedMocks.seedCatalogJobs).toHaveBeenCalledOnce(); + }); +}); diff --git a/front_admin/tests/app/layouts/NotificationButton.test.tsx b/front_admin/tests/app/layouts/NotificationButton.test.tsx new file mode 100644 index 00000000..602150af --- /dev/null +++ b/front_admin/tests/app/layouts/NotificationButton.test.tsx @@ -0,0 +1,194 @@ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NotificationButton } from "../../../src/app/layouts/MainLayout/Header/components/NotificationButton"; +import type { DashboardOverview } from "../../../src/lib/api/types"; +import { renderWithProviders } from "../../test-utils"; + +const dashboardApiMocks = vi.hoisted(() => ({ + getOverview: vi.fn(), +})); + +vi.mock("../../../src/lib/api/dashboard.api", () => ({ + dashboardApi: dashboardApiMocks, +})); + +function overview( + overrides: Partial = {}, +): DashboardOverview { + return { + stats: { + totalUsers: 12, + activeUsers: 8, + totalCollectedJobs: 1234, + jobsCollectedToday: 20, + }, + services: { + status: "ok", + timestamp: "2026-09-30T10:20:30.000Z", + services: { + postgres: { status: "ok", latencyMs: 12 }, + valkey: { status: "ok", latencyMs: 4 }, + scraper: { status: "ok", latencyMs: 2 }, + }, + }, + scrapers: [], + generatedAt: "2026-09-30T10:20:30.000Z", + ...overrides, + }; +} + +function openNotifications() { + fireEvent.click(screen.getByRole("button", { name: "Abrir notificações" })); +} + +describe("NotificationButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + dashboardApiMocks.getOverview.mockReset(); + dashboardApiMocks.getOverview.mockResolvedValue(overview()); + }); + + it("shows a healthy summary when services and scrapers are healthy", async () => { + renderWithProviders(); + openNotifications(); + + expect(await screen.findByText("Operação saudável")).toBeInTheDocument(); + expect(screen.getByText("1 reais")).toBeInTheDocument(); + expect(screen.getByText(/1\.234 vagas no índice/)).toBeInTheDocument(); + expect(screen.getByText(/Snapshot/)).toBeInTheDocument(); + }); + + it("maps service and scraper statuses to useful notifications", async () => { + dashboardApiMocks.getOverview.mockResolvedValueOnce( + overview({ + generatedAt: "bad timestamp", + services: { + status: "degraded", + timestamp: "2026-09-30T10:20:30.000Z", + services: { + postgres: { status: "down", error: "database offline" }, + valkey: { status: "degraded", latencyMs: 88 }, + scraper: { status: "ok" }, + }, + }, + scrapers: [ + { + name: "Lever", + status: "running", + running: true, + lastRunAt: null, + jobsCollected: null, + }, + { + name: "Adzuna", + status: "down", + running: false, + lastRunAt: null, + jobsCollected: null, + }, + ], + }), + ); + + renderWithProviders(); + openNotifications(); + + expect(await screen.findByText("Postgres indisponível")).not.toBeNull(); + expect( + screen.getAllByText("Snapshot Snapshot recente").length, + ).toBeGreaterThan(0); + expect(screen.getByText("database offline")).not.toBeNull(); + expect(screen.getByText("Valkey degradado")).not.toBeNull(); + expect(screen.getByText("Latência atual: 88ms.")).not.toBeNull(); + expect(screen.getByText("Lever em execução")).not.toBeNull(); + expect(screen.getByText("Adzuna indisponível")).not.toBeNull(); + expect(screen.queryByText("Scraper indisponível")).toBeNull(); + }); + + it("limits notifications to five and routes a selected item to the provider", async () => { + dashboardApiMocks.getOverview.mockResolvedValueOnce( + overview({ + generatedAt: "bad timestamp", + services: { + status: "down", + timestamp: "bad timestamp", + services: { + postgres: { status: "down" }, + valkey: { status: "down" }, + scraper: { status: "down" }, + }, + }, + scrapers: [ + { + name: "One", + status: "down", + running: false, + lastRunAt: null, + jobsCollected: null, + }, + { + name: "Two", + status: "down", + running: false, + lastRunAt: null, + jobsCollected: null, + }, + { + name: "Three", + status: "down", + running: false, + lastRunAt: null, + jobsCollected: null, + }, + ], + }), + ); + + renderWithProviders(); + openNotifications(); + + const firstNotification = await screen.findByRole("button", { + name: /Postgres indisponível/, + }); + expect(screen.getAllByText("Snapshot Snapshot recente")).toHaveLength(5); + expect(screen.getAllByRole("button").filter((button) => + button.textContent?.includes("indisponível"), + )).toHaveLength(5); + + fireEvent.click(firstNotification); + expect(screen.getByRole("status")).toHaveTextContent("Postgres indisponível"); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Abrir notificações" })); + expect(screen.getByText("Notificações")).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + await waitFor(() => { + expect(screen.queryByText("Notificações")).not.toBeInTheDocument(); + }); + }); + + it("shows loading while the request is pending and a fallback after failure", async () => { + let resolveOverview!: (result: DashboardOverview) => void; + dashboardApiMocks.getOverview.mockReturnValueOnce( + new Promise((resolve) => { + resolveOverview = resolve; + }), + ); + + const view = renderWithProviders(); + openNotifications(); + expect(await screen.findByText("Carregando")).toBeInTheDocument(); + resolveOverview(overview()); + expect(await screen.findByText("Operação saudável")).toBeInTheDocument(); + + dashboardApiMocks.getOverview.mockRejectedValueOnce(new Error("offline")); + fireEvent.click(screen.getByRole("button", { name: "Abrir notificações" })); + fireEvent.click(screen.getByRole("button", { name: "Abrir notificações" })); + expect(await screen.findByText("Dashboard indisponível")).toBeInTheDocument(); + expect( + screen.getByText("Não foi possível carregar notificações reais agora."), + ).toBeInTheDocument(); + view.unmount(); + }); +}); diff --git a/front_admin/tests/app/layouts/SidebarFooter.test.tsx b/front_admin/tests/app/layouts/SidebarFooter.test.tsx new file mode 100644 index 00000000..5813b141 --- /dev/null +++ b/front_admin/tests/app/layouts/SidebarFooter.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SidebarFooter } from "../../../src/app/layouts/MainLayout/Sidebar/components/SidebarFooter"; + +describe("SidebarFooter", () => { + it("disables logout while pending and restores the button when complete", async () => { + let finishLogout!: () => void; + const onLogout = vi.fn( + () => + new Promise((resolve) => { + finishLogout = resolve; + }), + ); + render(); + + const logoutButton = screen.getByRole("button", { name: "Sair" }); + fireEvent.click(logoutButton); + + expect(onLogout).toHaveBeenCalledOnce(); + expect(await screen.findByRole("button", { name: "Saindo..." })).toBeDisabled(); + + finishLogout(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Sair" })).toBeEnabled(); + }); + }); +}); diff --git a/front_admin/tests/app/layouts/TimeFilter.test.tsx b/front_admin/tests/app/layouts/TimeFilter.test.tsx new file mode 100644 index 00000000..01971924 --- /dev/null +++ b/front_admin/tests/app/layouts/TimeFilter.test.tsx @@ -0,0 +1,17 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TimeFilter } from "../../../src/app/layouts/MainLayout/Header/components/TimeFilter"; + +describe("TimeFilter", () => { + it("shows the selected range and toggles to the other supported range", () => { + const onChange = vi.fn(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /Últimas 24 horas/ })); + expect(onChange).toHaveBeenCalledWith("7d"); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: /Últimos 7 dias/ })); + expect(onChange).toHaveBeenLastCalledWith("24h"); + }); +}); diff --git a/front_admin/tests/modules/audit/AuditPage.test.tsx b/front_admin/tests/modules/audit/AuditPage.test.tsx index 7f482ed7..86c9cde3 100644 --- a/front_admin/tests/modules/audit/AuditPage.test.tsx +++ b/front_admin/tests/modules/audit/AuditPage.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AuditPage } from "../../../src/modules/audit/AuditPage"; import { auditApi } from "../../../src/lib/api/audit.api"; +import { AuditPage } from "../../../src/modules/audit/AuditPage"; import { renderWithProviders } from "../../test-utils"; vi.mock("../../../src/lib/api/audit.api", () => ({ @@ -100,4 +100,33 @@ describe("AuditPage", () => { await screen.findByText(/Nao foi possivel carregar auditoria/), ).toBeInTheDocument(); }); + + it("classifies each supported sensitive action as critical", async () => { + const criticalLogs = [ + "users.block", + "users.reset_password", + "users.change_role", + "scrapers.trigger", + "scrapers.reprocess", + "dashboard.read", + ].map((action, index) => ({ + ...logs[0], + id: index + 10, + action, + })); + vi.mocked(auditApi.list).mockResolvedValueOnce({ + data: criticalLogs, + total: criticalLogs.length, + limit: 20, + offset: 0, + }); + + renderWithProviders(); + + await screen.findByText("scrapers.reprocess"); + expect( + screen.getByText("Eventos críticos").parentElement, + ).toHaveTextContent("5"); + expect(screen.getAllByText("dashboard.read")).toHaveLength(2); + }); }); diff --git a/front_admin/tests/modules/dashboard/DashboardPage.test.tsx b/front_admin/tests/modules/dashboard/DashboardPage.test.tsx index 52e65056..e71e1409 100644 --- a/front_admin/tests/modules/dashboard/DashboardPage.test.tsx +++ b/front_admin/tests/modules/dashboard/DashboardPage.test.tsx @@ -96,4 +96,18 @@ describe("DashboardPage", () => { ).toBeInTheDocument(); expect(screen.getByText("backend offline")).toBeInTheDocument(); }); + + it("keeps data visible and disables refresh after a background refresh error", () => { + vi.mocked(useDashboard).mockReturnValue({ + ...dashboardState, + error: "snapshot temporariamente indisponível", + isRefreshing: true, + }); + + renderWithProviders(); + + expect(screen.getByText("Monitoramento em tempo real")).toBeInTheDocument(); + expect(screen.getByText("snapshot temporariamente indisponível")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Atualizar" })).toBeDisabled(); + }); }); diff --git a/front_admin/tests/modules/dashboard/components/PlatformChart.test.tsx b/front_admin/tests/modules/dashboard/components/PlatformChart.test.tsx index 97aad3c6..6f17dc37 100644 --- a/front_admin/tests/modules/dashboard/components/PlatformChart.test.tsx +++ b/front_admin/tests/modules/dashboard/components/PlatformChart.test.tsx @@ -41,5 +41,28 @@ describe("PlatformChart", () => { expect(screen.getByText("Estado do índice")).toBeInTheDocument(); expect(screen.getByText("+1")).toBeInTheDocument(); expect(container.querySelectorAll("circle")).toHaveLength(0); + + rerender( + , + ); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + expect(screen.getByText("Todos os intervalos ficaram em 0. O scraper não alterou o total indexado durante esta janela.")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getAllByText("-10")).toHaveLength(2); + expect(container.querySelector("span.bg-rose-500")).not.toBeNull(); }); }); diff --git a/front_admin/tests/modules/dashboard/hooks/dashboard-hooks.test.tsx b/front_admin/tests/modules/dashboard/hooks/dashboard-hooks.test.tsx index 1d38c635..0e704675 100644 --- a/front_admin/tests/modules/dashboard/hooks/dashboard-hooks.test.tsx +++ b/front_admin/tests/modules/dashboard/hooks/dashboard-hooks.test.tsx @@ -1,12 +1,13 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { NotificationProvider } from "../../../../src/components/notifications/NotificationProvider"; -import { dashboardService } from "../../../../src/modules/dashboard/services/dashboard.service"; +import { ApiError } from "../../../../src/lib/api/client"; import { useDashboard } from "../../../../src/modules/dashboard/hooks/useDashboard"; import { useDashboardMetrics } from "../../../../src/modules/dashboard/hooks/useDashboardMetrics"; import { useDashboardScrapers } from "../../../../src/modules/dashboard/hooks/useDashboardScrapers"; import { useDashboardServices } from "../../../../src/modules/dashboard/hooks/useDashboardServices"; +import { dashboardService } from "../../../../src/modules/dashboard/services/dashboard.service"; vi.mock("../../../../src/modules/dashboard/services/dashboard.service", () => ({ dashboardService: { @@ -46,6 +47,7 @@ function wrapper({ children }: { children: ReactNode }) { describe("dashboard hooks", () => { beforeEach(() => { + vi.clearAllMocks(); vi.mocked(dashboardService.getOverview).mockResolvedValue({ stats, resources, @@ -101,6 +103,78 @@ describe("dashboard hooks", () => { ); }); + it("labels invalid snapshots and caps history to the latest 24 points", async () => { + let snapshot = 0; + vi.mocked(dashboardService.getOverview).mockImplementation(async () => ({ + stats: { + ...stats, + totalJobs: { value: snapshot, trend: "ok", positive: true }, + }, + resources, + services, + scrapers, + generatedAt: + snapshot === 0 + ? "invalid timestamp" + : new Date(Date.UTC(2026, 0, 1, 0, snapshot)).toISOString(), + })); + + const { result } = renderHook(() => useDashboard(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.chartPoints[0].label).toBe("--:--"); + + for (let index = 1; index <= 25; index += 1) { + snapshot = index; + await act(async () => { + await result.current.refresh(); + }); + } + + expect(result.current.chartPoints).toHaveLength(24); + expect(result.current.chartPoints[0].totalJobs).toBe(2); + expect(result.current.chartPoints.at(-1)?.totalJobs).toBe(25); + }); + + it("notifies differently for an already-running scraper and other failures", async () => { + const { result } = renderHook(() => useDashboard(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + vi.mocked(dashboardService.toggleScraper).mockRejectedValueOnce( + new ApiError(409, { message: "already running" }), + ); + act(() => result.current.toggleScraper("adzuna")); + expect(await screen.findByText("Scraper já em execução")).toBeInTheDocument(); + await waitFor(() => + expect(dashboardService.getOverview).toHaveBeenCalledTimes(2), + ); + + vi.mocked(dashboardService.toggleScraper).mockRejectedValueOnce( + new Error("backend unavailable"), + ); + act(() => result.current.toggleScraper("adzuna")); + expect(await screen.findByText("Pausa indisponível")).toBeInTheDocument(); + }); + + it("reports a failed start distinctly for an idle scraper", async () => { + vi.mocked(dashboardService.getOverview).mockResolvedValueOnce({ + stats, + resources, + services, + scrapers: [{ ...scrapers[0], active: false }], + generatedAt: "2026-01-01T10:00:00.000Z", + }); + const { result } = renderHook(() => useDashboard(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + vi.mocked(dashboardService.toggleScraper).mockRejectedValueOnce( + new Error("cannot start"), + ); + + act(() => result.current.toggleScraper("adzuna")); + + expect(await screen.findByText("Erro ao iniciar scraper")).toBeInTheDocument(); + expect(screen.getByText("Não foi possível iniciar Adzuna.")).toBeInTheDocument(); + }); + it("loads segmented metric, service and scraper hooks", async () => { const metrics = renderHook(() => useDashboardMetrics()); await waitFor(() => expect(metrics.result.current.stats).toEqual(stats)); diff --git a/front_admin/tests/modules/observability/ObservabilityPage.test.tsx b/front_admin/tests/modules/observability/ObservabilityPage.test.tsx index b7bd0486..a930e9ed 100644 --- a/front_admin/tests/modules/observability/ObservabilityPage.test.tsx +++ b/front_admin/tests/modules/observability/ObservabilityPage.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ObservabilityPage } from "../../../src/modules/observability/ObservabilityPage"; import { observabilityApi } from "../../../src/lib/api/observability.api"; +import { ObservabilityPage } from "../../../src/modules/observability/ObservabilityPage"; import { renderWithProviders } from "../../test-utils"; vi.mock("../../../src/lib/api/observability.api", () => ({ @@ -63,6 +63,7 @@ const dashboardsPayload = { describe("ObservabilityPage", () => { beforeEach(() => { + vi.clearAllMocks(); vi.mocked(observabilityApi.health).mockResolvedValue({ status: "ok", timestamp: "2026-01-01T10:00:00.000Z", @@ -115,4 +116,86 @@ describe("ObservabilityPage", () => { await screen.findByText(/Sem resposta das series detalhadas/), ).toBeInTheDocument(); }); + + it("shows unavailable metrics, degraded infrastructure, and empty dashboards", async () => { + vi.mocked(observabilityApi.health).mockResolvedValueOnce({ + status: "down", + timestamp: "2026-01-01T10:00:00.000Z", + services: { + postgres: { status: "down", error: "connection refused" }, + valkey: { status: "degraded" }, + scraper: { status: "ok", latencyMs: 4 }, + }, + }); + vi.mocked(observabilityApi.metrics).mockResolvedValueOnce({ + requestRatePerMinute: null, + errorRatePct: null, + p95LatencyMs: null, + cacheHitRatePct: null, + activeSessionsCount: null, + }); + vi.mocked(observabilityApi.dashboards).mockResolvedValueOnce({ + ...dashboardsPayload, + dashboards: [], + }); + + renderWithProviders(); + + expect(await screen.findAllByText("N/D")).toHaveLength(3); + expect(screen.getByText("connection refused")).toBeInTheDocument(); + expect(screen.getByText("degraded")).toBeInTheDocument(); + expect(screen.queryByText("Requests/sec")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Atualizar" })); + await waitFor(() => { + expect(observabilityApi.health).toHaveBeenCalledTimes(2); + expect(observabilityApi.metrics).toHaveBeenCalledTimes(2); + expect(observabilityApi.dashboards).toHaveBeenCalledTimes(2); + }); + }); + + it("marks latency and error metrics above their attention limits", async () => { + vi.mocked(observabilityApi.metrics).mockResolvedValueOnce({ + requestRatePerMinute: 0, + errorRatePct: 5, + p95LatencyMs: 200, + cacheHitRatePct: 0, + activeSessionsCount: 0, + }); + + renderWithProviders(); + + await screen.findByText("Requisicoes por minuto"); + const latencyCard = screen.getByText("Latencia p95").parentElement; + const errorCard = screen.getByText("Taxa de erro").parentElement; + expect(latencyCard).toHaveTextContent("200ms"); + expect(latencyCard?.querySelector("span.text-rose-600")).not.toBeNull(); + expect(errorCard).toHaveTextContent("5%"); + expect(errorCard?.querySelector("span.text-rose-600")).not.toBeNull(); + }); + + it("selects the first available dashboard when the prior selection is absent", async () => { + vi.mocked(observabilityApi.dashboards).mockResolvedValueOnce({ + ...dashboardsPayload, + dashboards: [dashboardsPayload.dashboards[1]], + }); + + renderWithProviders(); + + expect(await screen.findByText("Infraestrutura")).toBeInTheDocument(); + expect(screen.getByText("Host")).toBeInTheDocument(); + expect(screen.queryByText("API Overview")).not.toBeInTheDocument(); + + for (const [label, range] of [ + ["5 min", "5m"], + ["15 min", "15m"], + ["6 h", "6h"], + ["24 h", "24h"], + ]) { + fireEvent.click(screen.getByRole("button", { name: label })); + await waitFor(() => { + expect(observabilityApi.dashboards).toHaveBeenCalledWith(range); + }); + } + }); }); diff --git a/front_admin/tests/modules/observability/components/ObservabilityPanelCard.test.tsx b/front_admin/tests/modules/observability/components/ObservabilityPanelCard.test.tsx index 07dc55e4..00d05921 100644 --- a/front_admin/tests/modules/observability/components/ObservabilityPanelCard.test.tsx +++ b/front_admin/tests/modules/observability/components/ObservabilityPanelCard.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { ObservabilityPanelCard } from "../../../../src/modules/observability/components/ObservabilityPanelCard"; +import { describe, expect, it, vi } from "vitest"; import type { ObservabilityPanel } from "../../../../src/lib/api/types"; +import { ObservabilityPanelCard } from "../../../../src/modules/observability/components/ObservabilityPanelCard"; const basePoints = [ { timestamp: "2026-01-01T10:00:00.000Z", value: 1 }, @@ -71,4 +71,141 @@ describe("ObservabilityPanelCard", () => { ); expect(screen.getAllByText("No data")).toHaveLength(2); }); + + it("formats count, bytes, seconds, and missing values at their boundaries", () => { + const makeStatPanel = ( + unit: ObservabilityPanel["unit"], + value: number | null, + ): ObservabilityPanel => ({ + id: `stat-${unit}`, + title: `Stat ${unit}`, + unit, + visualization: "stat", + series: [ + { + label: "Value", + points: [{ timestamp: "2026-01-01T10:00:00.000Z", value }], + }, + ], + }); + const { rerender } = render( + , + ); + + expect(screen.getByText("1.250")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("12.346")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("2.0 KiB")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("1048576.0 GiB")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("30.5 s")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("60.0 s")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No data")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("1.25%")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("66 ms")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("5.12 s")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("60.0 min")).toBeInTheDocument(); + }); + + it("shows nearest series values on hover and clears the tooltip on leave", () => { + const { container } = render( + , + ); + const chart = container.querySelector("svg") as SVGSVGElement; + vi.spyOn(chart, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 760, + bottom: 236, + width: 760, + height: 236, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + fireEvent.mouseMove(chart, { clientX: 700, clientY: 100 }); + const tooltip = container.querySelector("div.absolute.top-4"); + expect(tooltip).toHaveTextContent("Serie A"); + expect(tooltip).toHaveTextContent("2.048"); + expect(tooltip).toHaveStyle({ right: "12px" }); + + fireEvent.mouseLeave(chart); + expect(screen.queryByText("2.048")).not.toBeInTheDocument(); + + fireEvent.mouseMove(chart, { clientX: 80, clientY: 100 }); + const leftTooltip = container.querySelector("div.absolute.top-4"); + expect(leftTooltip?.style.left).toContain("calc("); + expect(leftTooltip?.style.right).toBe("auto"); + }); + + it("restores all visible series if a selected series disappears", () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Serie A" })); + rerender( + , + ); + + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Serie C" })).toBeInTheDocument(); + }); + + it("does not expose non-finite metric values as chart points", () => { + render( + , + ); + + expect(screen.getAllByText("No data")).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Invalid" })).not.toBeInTheDocument(); + }); }); diff --git a/front_admin/tests/modules/permissions/PermissionsPage.test.tsx b/front_admin/tests/modules/permissions/PermissionsPage.test.tsx index 28759520..ea3baeed 100644 --- a/front_admin/tests/modules/permissions/PermissionsPage.test.tsx +++ b/front_admin/tests/modules/permissions/PermissionsPage.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { permissionsApi } from "../../../src/lib/api/permissions.api"; -import { PermissionsPage } from "../../../src/modules/permissions/PermissionsPage"; import { useAuth } from "../../../src/modules/auth/hooks/useAuth"; +import { PermissionsPage } from "../../../src/modules/permissions/PermissionsPage"; import { renderWithProviders } from "../../test-utils"; vi.mock("../../../src/lib/api/permissions.api", () => ({ @@ -35,6 +35,7 @@ const rules = [ describe("PermissionsPage", () => { beforeEach(() => { + vi.clearAllMocks(); vi.mocked(useAuth).mockReturnValue({ isLoggedIn: { id: "u1", @@ -109,4 +110,54 @@ describe("PermissionsPage", () => { await screen.findByText("Modo leitura. Apenas super admins podem alterar regras."); expect(screen.getByRole("button", { name: "Salvar" })).toBeDisabled(); }); + + it("keeps critical rules immutable and does not save unchanged rules", async () => { + renderWithProviders(); + + await screen.findByText("Matriz de Permissões"); + expect(screen.getByText("imutável")).toBeInTheDocument(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.getByRole("button", { name: "Salvar" })).toBeDisabled(); + expect(permissionsApi.update).not.toHaveBeenCalled(); + }); + + it("reports a backend rejection and exits the saving state", async () => { + vi.mocked(permissionsApi.update).mockRejectedValueOnce( + new Error("permission denied"), + ); + renderWithProviders(); + + await screen.findByText("Matriz de Permissões"); + fireEvent.change(screen.getByDisplayValue("Suporte"), { + target: { value: "admin" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Salvar 1/ })); + + expect( + await screen.findByText("Erro ao salvar permissões"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Salvar 1/ })).toBeEnabled(); + }); + + it("shows the pending save state until the API responds", async () => { + let finishUpdate!: (value: { rules: typeof rules }) => void; + vi.mocked(permissionsApi.update).mockReturnValueOnce( + new Promise((resolve) => { + finishUpdate = resolve; + }), + ); + renderWithProviders(); + + await screen.findByText("Matriz de Permissões"); + fireEvent.change(screen.getByDisplayValue("Suporte"), { + target: { value: "admin" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Salvar 1/ })); + expect(await screen.findByRole("button", { name: "Salvando..." })).toBeDisabled(); + + finishUpdate({ + rules: [{ ...rules[0], minRole: "admin", customized: true }, rules[1]], + }); + await screen.findByText("Permissões atualizadas"); + }); }); diff --git a/front_admin/tests/modules/scrapers/ScrapersPage.test.tsx b/front_admin/tests/modules/scrapers/ScrapersPage.test.tsx index 87408580..d189bf9f 100644 --- a/front_admin/tests/modules/scrapers/ScrapersPage.test.tsx +++ b/front_admin/tests/modules/scrapers/ScrapersPage.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ScrapersPage } from "../../../src/modules/scrapers/ScrapersPage"; import { useAuth } from "../../../src/modules/auth/hooks/useAuth"; import { useScrapers } from "../../../src/modules/scrapers/hooks/useScrapers"; +import { ScrapersPage } from "../../../src/modules/scrapers/ScrapersPage"; import { renderWithProviders } from "../../test-utils"; vi.mock("../../../src/modules/scrapers/hooks/useScrapers", () => ({ @@ -161,4 +161,46 @@ describe("ScrapersPage", () => { expect(screen.getByText("Carregando scrapers...")).toBeInTheDocument(); expect(screen.getByText("falha")).toBeInTheDocument(); }); + + it("renders empty adapters and jobs with no snapshot timestamp", () => { + vi.mocked(useScrapers).mockReturnValue({ + ...scraperState, + adapterStats: [], + jobPreviews: [], + overview: { + ...scraperState.overview, + lastUpdatedAt: null, + adaptersCount: 0, + sourcesCount: 0, + loadedJobs: 0, + }, + }); + + renderWithProviders(); + + expect(screen.getByText(/último snapshot Sem atualização/)).toBeInTheDocument(); + expect( + screen.getByText("Nenhum adapter encontrado no payload de vagas."), + ).toBeInTheDocument(); + expect( + screen.getByText("Nenhuma vaga carregada do backend ainda."), + ).toBeInTheDocument(); + }); + + it("disables cache and refresh actions while each request is pending", () => { + vi.mocked(useScrapers).mockReturnValue({ + ...scraperState, + isRefreshing: true, + isClearingJobsCache: true, + }); + + renderWithProviders(); + + expect( + screen.getByRole("button", { name: /limpando cache/i }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Recarregar dados" }), + ).toBeDisabled(); + }); }); diff --git a/front_admin/tests/modules/scrapers/hooks/useScrapers.test.tsx b/front_admin/tests/modules/scrapers/hooks/useScrapers.test.tsx index 144e8027..bd139a46 100644 --- a/front_admin/tests/modules/scrapers/hooks/useScrapers.test.tsx +++ b/front_admin/tests/modules/scrapers/hooks/useScrapers.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { NotificationProvider } from "../../../../src/components/notifications/NotificationProvider"; @@ -71,6 +71,7 @@ const jobsPayload = { describe("useScrapers", () => { beforeEach(() => { + vi.clearAllMocks(); vi.mocked(scrapersApi.list).mockResolvedValue(scraperList); vi.mocked(scrapersApi.jobsCount).mockResolvedValue({ total: 2 }); vi.mocked(scrapersApi.jobs).mockResolvedValue(jobsPayload); @@ -184,6 +185,136 @@ describe("useScrapers", () => { expect(result.current.logs[0].text).toContain("Scraper ja esta em execucao"); }); + it("does not trigger an active scraper and reports its running state", async () => { + vi.mocked(scrapersApi.list).mockResolvedValueOnce({ + scrapers: [{ ...scraperList.scrapers[0], running: true, status: "running" }], + }); + const { result } = renderHook(() => useScrapers(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.toggleScraper("Adzuna"); + }); + + expect(scrapersApi.triggerOne).not.toHaveBeenCalled(); + expect(result.current.logs[0].text).toBe("Adzuna ja esta em execucao."); + expect(await screen.findByText("Scraper já em execução")).toBeInTheDocument(); + }); + + it("treats an individual 409 as recoverable and clears the starting state", async () => { + const { result } = renderHook(() => useScrapers(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + vi.mocked(scrapersApi.triggerOne).mockRejectedValueOnce( + new ApiError(409, { message: "already running" }), + ); + + await act(async () => { + await result.current.toggleScraper("Adzuna"); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.isStarting).toBe(false); + expect(result.current.logs[0].text).toBe("Adzuna ja esta em execucao."); + expect(scrapersApi.list).toHaveBeenCalledTimes(2); + }); + + it("uses fallback labels for invalid run dates, empty sources and keywords", async () => { + vi.mocked(scrapersApi.list).mockResolvedValueOnce({ + scrapers: [ + { + name: "Custom", + status: "idle", + running: false, + lastRunAt: "invalid date", + jobsCollected: null, + }, + ], + }); + vi.mocked(scrapersApi.jobs).mockResolvedValueOnce({ + total: 2, + jobs: [ + { + ...jobsPayload.jobs[0], + id: "job-unknown", + source: "", + sources: [",", " "], + keyword: "", + keywords: [], + postedAt: "invalid date", + }, + { + ...jobsPayload.jobs[1], + id: "job-custom", + source: "Custom adapter", + sources: [], + keyword: "", + keywords: ["python"], + postedAt: "", + }, + ], + }); + + const { result } = renderHook(() => useScrapers(), { wrapper }); + await waitFor(() => expect(result.current.overview.loadedJobs).toBe(2)); + + expect(result.current.scrapers[0].lastRun).toBe("invalid date"); + expect(result.current.adapterStats.map((adapter) => adapter.name)).toContain( + "desconhecido", + ); + expect(result.current.adapterStats.map((adapter) => adapter.name)).toContain( + "Custom adapter", + ); + expect(result.current.jobPreviews.map((job) => job.keyword)).toContain( + "sem keyword", + ); + expect(result.current.jobPreviews.map((job) => job.keyword)).toContain( + "python", + ); + }); + + it("maps remaining adapter families and supplies fallback action messages", async () => { + vi.mocked(scrapersApi.jobs).mockResolvedValueOnce({ + total: 4, + jobs: ["The Muse", "Jooble", "linkedin", "Unknown"].map( + (source, index) => ({ + ...jobsPayload.jobs[0], + id: `family-${index}`, + source, + sources: [], + keyword: "keyword", + keywords: [], + }), + ), + }); + vi.mocked(scrapersApi.trigger).mockResolvedValueOnce({ + ok: true, + message: "", + }); + vi.mocked(scrapersApi.triggerOne).mockResolvedValueOnce({ + ok: true, + message: "", + scraper: "Adzuna", + }); + + const { result } = renderHook(() => useScrapers(), { wrapper }); + await waitFor(() => expect(result.current.overview.loadedJobs).toBe(4)); + + expect(result.current.adapterStats.map((adapter) => adapter.name)).toEqual( + ["The Muse", "Jooble", "linkedin", "Unknown"], + ); + await act(async () => { + await result.current.startAll(); + await result.current.toggleScraper("Adzuna"); + }); + + expect(result.current.logs.map((entry) => entry.text)).toContain( + "Execucao dos scrapers iniciada.", + ); + expect(result.current.logs.map((entry) => entry.text)).toContain( + "Adzuna iniciado.", + ); + }); + it("handles cache clearing failures", async () => { const { result } = renderHook(() => useScrapers(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -216,4 +347,24 @@ describe("useScrapers", () => { "Scheduler reportou execução em andamento.", ); }); + + it("logs when the scheduler reports that the last running scraper became idle", async () => { + vi.mocked(scrapersApi.list) + .mockResolvedValueOnce({ + scrapers: [{ ...scraperList.scrapers[0], running: true, status: "running" }], + }) + .mockResolvedValueOnce({ + scrapers: [{ ...scraperList.scrapers[0], running: false, status: "idle" }], + }); + const { result } = renderHook(() => useScrapers(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.logs.map((entry) => entry.text)).toContain( + "Scheduler reportou scraper ocioso.", + ); + }); }); diff --git a/front_admin/tests/modules/users/UsersPage.test.tsx b/front_admin/tests/modules/users/UsersPage.test.tsx index 5863211e..747301de 100644 --- a/front_admin/tests/modules/users/UsersPage.test.tsx +++ b/front_admin/tests/modules/users/UsersPage.test.tsx @@ -218,4 +218,81 @@ describe("UsersPage", () => { ); expect(adminUsersApi.changeRole).not.toHaveBeenCalled(); }); + + it("filters by username and active status", async () => { + renderWithProviders(); + + await screen.findByText("Ada Lovelace"); + const search = screen.getByPlaceholderText( + "Buscar por nome, email ou username", + ); + fireEvent.change(search, { target: { value: "root.user" } }); + expect(screen.getByText("root@example.com")).toBeInTheDocument(); + expect(screen.queryByText("ada@example.com")).not.toBeInTheDocument(); + + fireEvent.change(search, { target: { value: "" } }); + fireEvent.change(screen.getByDisplayValue("Todos os status"), { + target: { value: "active" }, + }); + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + expect(screen.queryByText("root@example.com")).not.toBeInTheDocument(); + }); + + it("uses fallback name, email, and initials for sparse backend users", async () => { + vi.mocked(adminUsersApi.list).mockResolvedValueOnce({ + data: [ + { + ...backendUsers[0], + id: "00000000-0000-4000-8000-000000000003", + displayName: null, + username: null, + email: null, + firstName: null, + lastName: null, + role: "user", + isBlocked: false, + }, + ], + total: 1, + limit: 50, + offset: 0, + }); + + renderWithProviders(); + + expect(await screen.findByText("Usuario sem nome")).toBeInTheDocument(); + expect(screen.getByText("sem email")).toBeInTheDocument(); + }); + + it("shows an error if deleting a user fails", async () => { + vi.mocked(adminUsersApi.delete).mockRejectedValueOnce( + new Error("delete failed"), + ); + renderWithProviders(); + + await screen.findByText("Ada Lovelace"); + fireEvent.click(screen.getAllByRole("button", { name: /editar/i })[0]); + fireEvent.click(screen.getByRole("button", { name: "Excluir usuário" })); + fireEvent.click(screen.getByRole("button", { name: "Confirmar exclusão" })); + + expect(await screen.findByText("Erro ao excluir usuário")).toBeInTheDocument(); + expect(screen.getAllByText("ada@example.com")).toHaveLength(2); + }); + + it("shows an error if changing a user's role fails", async () => { + vi.mocked(adminUsersApi.changeRole).mockRejectedValueOnce( + new Error("role update failed"), + ); + renderWithProviders(); + + await screen.findByText("Ada Lovelace"); + fireEvent.click(screen.getAllByRole("button", { name: /editar/i })[0]); + fireEvent.change(screen.getByLabelText("Perfil de acesso"), { + target: { value: "support" }, + }); + fireEvent.click(screen.getByRole("button", { name: /salvar alterações/i })); + + expect(await screen.findByText("Erro ao salvar usuário")).toBeInTheDocument(); + expect(screen.getAllByText("ada@example.com")).toHaveLength(2); + }); }); diff --git a/front_admin/tests/modules/users/components/UserList.test.tsx b/front_admin/tests/modules/users/components/UserList.test.tsx index 5f392187..9b0b47af 100644 --- a/front_admin/tests/modules/users/components/UserList.test.tsx +++ b/front_admin/tests/modules/users/components/UserList.test.tsx @@ -55,4 +55,32 @@ describe("UserList", () => { "https://example.com/ada.png", ); }); + + it("falls back to initials when an avatar request fails", () => { + render( + , + ); + + fireEvent.error(screen.getByAltText("Foto de Ada Lovelace")); + + expect(screen.queryByAltText("Foto de Ada Lovelace")).not.toBeInTheDocument(); + expect(screen.getByText("AL")).toBeInTheDocument(); + }); + + it("uses a neutral date label for missing and invalid login dates", () => { + render( + , + ); + + expect(screen.getAllByText("Sem registro")).toHaveLength(2); + }); }); diff --git a/front_admin/vite.config.ts b/front_admin/vite.config.ts index 67f7627e..43f1e927 100644 --- a/front_admin/vite.config.ts +++ b/front_admin/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ globals: true, setupFiles: ["./tests/setup.ts"], include: ["tests/**/*.test.{ts,tsx}"], + maxWorkers: 2, coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/frontend/tests/unit/components/login/RigthSide.test.tsx b/frontend/tests/unit/components/login/RigthSide.test.tsx index 4f245fbf..b068b8bd 100644 --- a/frontend/tests/unit/components/login/RigthSide.test.tsx +++ b/frontend/tests/unit/components/login/RigthSide.test.tsx @@ -131,7 +131,7 @@ describe("RigthSide", () => { }); it("mostra loading durante requisição", async () => { - mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 200))); + mockLogin.mockImplementation(() => new Promise(() => {})); render(); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "teste@email.com" } }); fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); @@ -140,7 +140,7 @@ describe("RigthSide", () => { }); it("desabilita inputs durante loading", async () => { - mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 200))); + mockLogin.mockImplementation(() => new Promise(() => {})); render(); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/senha/i); diff --git a/frontend/tests/unit/new_dashboard/HelpTab.test.tsx b/frontend/tests/unit/new_dashboard/HelpTab.test.tsx new file mode 100644 index 00000000..ba2e8296 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/HelpTab.test.tsx @@ -0,0 +1,41 @@ +import { HelpTab } from "@/domains/new_dashboard/components/help/HelpTab"; +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +describe("HelpTab", () => { + it("renders each FAQ question with its corresponding answer", () => { + render(); + + expect( + screen.getByRole("heading", { + name: "Perguntas Frequentes & Central de Ajuda", + }), + ).toBeInTheDocument(); + + const questions = [ + { + title: "Como o Match Score das vagas é calculado?", + answer: /compara as tecnologias do seu perfil/i, + }, + { + title: "O que é a agenda disponível dos mentores?", + answer: /sincronizados com a agenda interna/i, + }, + { + title: "Como posso mudar o meu status de candidatura?", + answer: /mover as vagas para acompanhar em tempo real/i, + }, + ]; + + expect(screen.getAllByRole("article")).toHaveLength(questions.length); + + for (const question of questions) { + const article = screen + .getByRole("heading", { name: question.title }) + .closest("article"); + + expect(article).not.toBeNull(); + expect(within(article as HTMLElement).getByText(question.answer)).toBeInTheDocument(); + } + }); +}); diff --git a/frontend/tests/unit/new_dashboard/page.test.tsx b/frontend/tests/unit/new_dashboard/page.test.tsx index 2581b9c6..b4bd8ed6 100644 --- a/frontend/tests/unit/new_dashboard/page.test.tsx +++ b/frontend/tests/unit/new_dashboard/page.test.tsx @@ -1,487 +1,889 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +/* eslint-disable @typescript-eslint/no-explicit-any */ import NewDashboardPage from "@/domains/new_dashboard/NewDashboardPage"; - -const mockUseAuth = vi.fn(); -const mockUseUserDashboardData = vi.fn(); -const mockUseDashboardJobs = vi.fn(); -let dashboardJobsState: { - trackedJobs: Array>; - recommendedJobs: Array>; - recommendedPagination: { - total: number; - page: number; - limit: number; - totalPages: number; - hasNext: boolean; - hasPrev: boolean; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/* -------------------------------------------------------------------------- */ +/* MOCKS DE MÓDULOS */ +/* ⚠️ caminhos SEMPRE com alias @/domains/new_dashboard/... */ +/* porque vi.mock resolve relativo ao ARQUIVO DE TESTE. */ +/* -------------------------------------------------------------------------- */ + +const mockNavigate = vi.fn(); +let mockPathname = "/"; +const mockSetSearchParams = vi.fn(); +let mockSearchParamsValue = new URLSearchParams(); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLocation: () => ({ pathname: mockPathname }), + useNavigate: () => mockNavigate, + useSearchParams: () => [mockSearchParamsValue, mockSetSearchParams], }; - isLoadingJobs: boolean; - isRefreshingJobs: boolean; - refreshRecommendations: ReturnType; - changeRecommendationsPage: ReturnType; - addTrackedJob: ReturnType; - changeJobStatus: ReturnType; - changeJobNotesLocally: ReturnType; - saveJobNotes: ReturnType; -}; +}); +const mockRefreshUser = vi.fn().mockResolvedValue(undefined); vi.mock("@/domains/auth/application/AuthContext", () => ({ - useAuth: () => mockUseAuth(), + useAuth: () => ({ + user: { id: "u1", name: "Tester" }, + refreshUser: mockRefreshUser, + }), })); -vi.mock("@/domains/new_dashboard/hooks/useUserDashboardData", () => ({ - useUserDashboardData: () => mockUseUserDashboardData(), +// ---------- Child components (paths com alias!) ---------- +vi.mock("@/domains/new_dashboard/components/dashboard/DashboardTab", () => ({ + DashboardTab: (props: any) => ( +
+ + + +
+ ), })); -vi.mock("@/domains/new_dashboard/hooks/useDashboardJobs", () => ({ - useDashboardJobs: () => mockUseDashboardJobs(), +vi.mock("@/domains/new_dashboard/components/help/HelpTab", () => ({ + HelpTab: () =>
Help
, })); -vi.mock("@/domains/new_dashboard/infrastructure/dashboardJobsApi", () => ({ - getDashboardSavedJobEvents: vi.fn().mockResolvedValue([]), - getDashboardApplicationNotes: vi.fn().mockResolvedValue([]), - createDashboardApplicationNote: vi.fn(), - updateDashboardApplicationNote: vi.fn(), - deleteDashboardApplicationNote: vi.fn(), +vi.mock("@/domains/new_dashboard/components/home/HomeTab", () => ({ + HomeTab: (props: any) => ( +
+ + +
+ ), })); -vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ - getDashboardNotificationFeed: vi.fn().mockResolvedValue({ - messages: [], - notifications: [], - unreadCount: 0, - }), - markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), - clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), +vi.mock("@/domains/new_dashboard/components/jobs/AddJobModal", () => ({ + AddJobModal: (props: any) => ( +
+ + +
+ ), +})); + +vi.mock("@/domains/new_dashboard/components/jobs/JobDetailModal", () => ({ + JobDetailModal: (props: any) => ( +
+ {props.job.id} + + + +
+ ), +})); + +vi.mock("@/domains/new_dashboard/components/jobs/JobTab", () => ({ + JobTab: (props: any) => ( +
+ + + + + + + + + + + +
+ ), +})); + +vi.mock("@/domains/new_dashboard/components/mentoring/MentoringTab", () => ({ + MentoringTab: () =>
, +})); + +vi.mock("@/domains/new_dashboard/components/profile/ProfileTab", () => ({ + ProfileTab: (props: any) => ( +
+ + +
+ ), +})); + +vi.mock("@/domains/new_dashboard/components/layout/Header", () => ({ + Header: () =>
, +})); +vi.mock("@/domains/new_dashboard/components/layout/MobileTabBar", () => ({ + MobileTabBar: () =>
, +})); +vi.mock("@/domains/new_dashboard/components/layout/Sidebar", () => ({ + Sidebar: () =>
, +})); + +vi.mock("@/domains/new_dashboard/components/shared/Toast", () => ({ + Toast: ({ message }: any) => + message ?
{message}
: null, +})); + +vi.mock("@/domains/new_dashboard/constants", () => ({ + jobStatuses: { applied: "Aplicada", interview: "Entrevista" }, +})); + +vi.mock("@/domains/new_dashboard/utils/searchKeywords", () => ({ + parseSearchKeywords: (s: string) => + s ? s.split(",").map((x) => x.trim()) : [], })); -function renderPage(pathname: string) { - return render( - - - , +vi.mock("@/domains/new_dashboard/utils/jobModelFilters", () => ({ + getModelFilterFromJobTypes: (types: string[]) => + types.includes("Remoto") ? "Remoto" : "Todos", + modelFilterToApiFilter: (f: string) => (f === "Todos" ? {} : { model: f }), +})); + +vi.mock("@/domains/new_dashboard/utils/locationFilters", () => ({})); + +// ---------- Hooks ---------- +const mockSetUserProfile = vi.fn(); +const mockSetSearchPreferences = vi.fn(); +const mockSaveUserProfile = vi.fn().mockResolvedValue(undefined); +const mockSaveSearchPreferences = vi.fn().mockResolvedValue(undefined); + +let mockUserData: any = {}; + +vi.mock("@/domains/new_dashboard/hooks/useUserDashboardData", () => ({ + useUserDashboardData: () => mockUserData, +})); + +const mockRefreshRecommendations = vi.fn().mockResolvedValue(undefined); +const mockAddTrackedJob = vi.fn().mockResolvedValue(undefined); +const mockChangeJobStatus = vi + .fn() + .mockImplementation((id: string, status: string) => + Promise.resolve({ id, status }), ); +const mockChangeJobNotesLocally = vi.fn(); +const mockSaveJobNotes = vi.fn().mockResolvedValue(undefined); + +let mockJobs: any = {}; + +vi.mock("@/domains/new_dashboard/hooks/useDashboardJobs", () => ({ + useDashboardJobs: () => mockJobs, +})); + +/* -------------------------------------------------------------------------- */ +/* FACTORIES */ +/* -------------------------------------------------------------------------- */ + +function defaultJobs(overrides: any = {}) { + mockJobs = { + trackedJobs: [], + recommendedJobs: [], + recommendedPagination: { page: 1, limit: 10, total: 0 }, + isRefreshingJobs: false, + refreshRecommendations: mockRefreshRecommendations, + addTrackedJob: mockAddTrackedJob, + changeJobStatus: mockChangeJobStatus, + changeJobNotesLocally: mockChangeJobNotesLocally, + saveJobNotes: mockSaveJobNotes, + ...overrides, + }; } -describe("NewDashboardPage", () => { - beforeEach(() => { - dashboardJobsState = { +function defaultUserData(overrides: any = {}) { + mockUserData = { + userProfile: { technologyExperiences: [] }, + setUserProfile: mockSetUserProfile, + searchPreferences: { jobTypes: [], careerChecklist: [] }, + setSearchPreferences: mockSetSearchPreferences, + isLoadingUserData: false, + isSavingProfile: false, + isSavingPreferences: false, + saveUserProfile: mockSaveUserProfile, + saveSearchPreferences: mockSaveSearchPreferences, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockPathname = "/"; + mockSearchParamsValue = new URLSearchParams(); + defaultJobs(); + defaultUserData(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/* ========================================================================== */ +/* A PARTIR DAQUI, O CORPO DOS TESTES É IDÊNTICO AO QUE VOCÊ JÁ TINHA */ +/* (só as chamadas de vi.mock acima precisavam mudar de caminho) */ +/* ========================================================================== */ + +describe("NewDashboardPage - roteamento por section", () => { + it.each([ + ["/dashboard", "dashboard-tab"], + ["/vagas", "job-tab"], + ["/mentoria", "mentoring-tab"], + ["/perfil", "profile-tab"], + ["/ajuda", "help-tab"], + ["/", "home-tab"], + ["/desconhecido", "home-tab"], + ])("renderiza a tab correta para %s", (pathname, testId) => { + mockPathname = pathname; + render(); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("mobile-tab-bar")).toBeInTheDocument(); + }); +}); + +describe("NewDashboardPage - jobId via URL", () => { + it("sincroniza jobId da URL, abre modal e limpa o parâmetro", async () => { + mockSearchParamsValue = new URLSearchParams("jobId=abc"); + const trackedJob = { + id: "abc", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "full-time", + level: "senior", + tags: [], + rawPayload: {}, + matchScore: 90, + } as any; + defaultJobs({ trackedJobs: [trackedJob] }); + + render(); + + await waitFor(() => + expect(screen.getByTestId("job-detail-modal")).toBeInTheDocument(), + ); + expect(screen.getByTestId("detail-job-id").textContent).toBe("abc"); + expect(mockSetSearchParams).toHaveBeenCalled(); + }); + + it("não faz nada quando não há jobId na URL", () => { + render(); + expect(screen.queryByTestId("job-detail-modal")).not.toBeInTheDocument(); + expect(mockSetSearchParams).not.toHaveBeenCalled(); + }); +}); + +describe("NewDashboardPage - scoreJobWithTechnologies (via DashboardTab)", () => { + const baseJob = (overrides: any = {}) => ({ + id: "j1", + jobTitle: "Frontend Developer", + company: "ACME", + location: "Brasil", + type: "full-time", + level: "senior", + tags: ["react", "typescript"], + rawPayload: {}, + matchScore: 0, + ...overrides, + }); + + it("mantém score quando rawPayload.matchSource é backend_profile", () => { + defaultJobs({ trackedJobs: [ - { - id: "job-1", - jobTitle: "Frontend Developer", - company: "ACME", - location: "Remoto", - salary: "A combinar", - type: "Remoto", - level: "Pleno", + baseJob({ matchScore: 88, - tags: ["React"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com", - source: "LinkedIn", - notes: "", - }, + rawPayload: { matchSource: "backend_profile" }, + }), + ], + }); + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("retorna job inalterado quando não há tecnologias", () => { + defaultJobs({ trackedJobs: [baseJob()] }); + mockUserData.userProfile.technologyExperiences = []; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("ignora tecnologias com nome vazio (branch filter)", () => { + defaultJobs({ trackedJobs: [baseJob()] }); + mockUserData.userProfile.technologyExperiences = [ + { name: " ", years: 3 }, + { name: "React", years: 5 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("atribui score 45 quando nenhuma tecnologia casa", () => { + defaultJobs({ trackedJobs: [baseJob({ tags: [] })] }); + mockUserData.userProfile.technologyExperiences = [ + { name: "cobol", years: 5 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("calcula score com tecnologias casadas e cobre aliases js", () => { + defaultJobs({ + trackedJobs: [ + baseJob({ + jobTitle: "Vaga Node js", + tags: ["Node JS"], + rawPayload: { description: "React and Vue" }, + }), ], + }); + mockUserData.userProfile.technologyExperiences = [ + { name: "Node js", years: 4 }, + { name: "react", years: 3 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("cobre branch matchAliases com 'js' no final (<- ' js')", () => { + defaultJobs({ + trackedJobs: [baseJob({ jobTitle: "Dev", tags: [] })], + }); + mockUserData.userProfile.technologyExperiences = [ + { name: "nodejs", years: 2 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("cobre textMatchesAlias com aliases contendo espaço", () => { + defaultJobs({ + trackedJobs: [ + baseJob({ + jobTitle: "Vaga de node js sênior", + tags: [], + rawPayload: {}, + }), + ], + }); + mockUserData.userProfile.technologyExperiences = [ + { name: "node js", years: 3 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("cobre rawPayload com arrays e valores não-string", () => { + defaultJobs({ + trackedJobs: [ + baseJob({ + rawPayload: { + lista: ["react", "vue"], + numero: 42, + nulo: null, + texto: "graphql", + }, + }), + ], + }); + mockUserData.userProfile.technologyExperiences = [ + { name: "react", years: 3 }, + ]; + mockPathname = "/dashboard"; + render(); + expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + }); + + it("não recalcula score em recommendedJobs quando matchScore > 0", () => { + defaultJobs({ recommendedJobs: [ { - id: "job-2", - jobTitle: "Backend Developer", - company: "Globex", - location: "Brasil", - salary: "A combinar", - type: "Híbrido", - level: "Sênior", - matchScore: 91, - tags: ["Node.js"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/2", - source: "Gupy", - notes: "", + id: "r1", + jobTitle: "Dev", + company: "C", + location: "BR", + type: "full-time", + level: "jr", + tags: [], + rawPayload: {}, + matchScore: 77, }, ], - recommendedPagination: { - total: 2, - page: 1, - limit: 50, - totalPages: 3, - hasNext: true, - hasPrev: false, - }, - isLoadingJobs: false, - isRefreshingJobs: false, - refreshRecommendations: vi.fn(), - changeRecommendationsPage: vi.fn(), - addTrackedJob: vi.fn(), - changeJobStatus: vi.fn(), - changeJobNotesLocally: vi.fn(), - saveJobNotes: vi.fn(), - }; - - mockUseAuth.mockReturnValue({ - user: { id: "user-1", email: "maria@exemplo.com" }, - refreshUser: vi.fn(), - logout: vi.fn(), }); + mockPathname = "/vagas"; + render(); + expect(screen.getByTestId("job-tab")).toBeInTheDocument(); + }); - mockUseUserDashboardData.mockReturnValue({ - userProfile: { - firstName: "Maria", - lastName: "Clara", - displayName: "Maria Clara", - username: "mariaclara", - email: "maria@exemplo.com", - avatarUrl: "", - phone: "(11) 99999-9999", - level: "Pleno", - technologies: ["React", "TypeScript"], - technologyExperiences: [ - { name: "React", years: 5 }, - { name: "TypeScript", years: 4 }, - ], - }, - setUserProfile: vi.fn(), - searchPreferences: { - keywords: ["React"], - searchLocation: "Brasil", - remoteOnly: false, - jobTypes: [], - emailNotifications: true, - careerChecklist: [], - }, - setSearchPreferences: vi.fn(), - isLoadingUserData: false, - isSavingProfile: false, - isSavingPreferences: false, - userDataError: "", - saveUserProfile: vi.fn(), - saveSearchPreferences: vi.fn(), - }); - - mockUseDashboardJobs.mockReturnValue(dashboardJobsState); - }); - - it("renderiza a home com saudação e vagas salvas", () => { - renderPage("/home"); - - expect( - screen.getByRole("heading", { name: /que bom te ver de volta/i }), - ).toBeInTheDocument(); - expect(screen.getByText(/frontend developer/i)).toBeInTheDocument(); - expect(screen.getByText(/vagas salvas recentes/i)).toBeInTheDocument(); - }); - - it("renderiza o dashboard de vagas", () => { - renderPage("/dashboard"); - - expect(screen.getByRole("heading", { name: /gerenciar vagas/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /adicionar vaga/i })).toBeInTheDocument(); - }); - - it("abre e salva uma nova vaga no dashboard", () => { - renderPage("/dashboard"); - - fireEvent.click(screen.getByRole("button", { name: /adicionar vaga/i })); - expect(screen.getByRole("heading", { name: /adicionar vaga/i })).toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText(/desenvolvedor frontend/i), { - target: { value: "Nova vaga" }, - }); - fireEvent.change(screen.getByPlaceholderText(/nome da empresa/i), { - target: { value: "ACME" }, - }); - fireEvent.change(screen.getByPlaceholderText(/https:\/\/\.\.\./i), { - target: { value: "https://example.com/new-job" }, - }); - fireEvent.click(screen.getByRole("button", { name: /salvar vaga/i })); - - expect(dashboardJobsState.addTrackedJob).toHaveBeenCalled(); - }); - - it("abre vaga salva e persiste notas ao concluir", () => { - renderPage("/dashboard"); - - fireEvent.click(screen.getByRole("button", { name: /frontend developer/i })); - expect(screen.getByRole("heading", { name: /frontend developer/i })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /concluir/i })); - - expect(dashboardJobsState.saveJobNotes).toHaveBeenCalled(); - }); - - it("renderiza a busca de vagas", () => { - renderPage("/vagas"); - - expect( - screen.getByRole("heading", { name: /vagas abertas recomendadas/i }), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /buscar vagas/i })).toBeInTheDocument(); - }); - - it("usa apenas vagas remotas como padrão quando a preferência está ativa", async () => { - mockUseUserDashboardData.mockReturnValue({ - ...mockUseUserDashboardData(), - searchPreferences: { - keywords: ["React"], - searchLocation: "Brasil", - remoteOnly: true, - jobTypes: ["Remoto"], - emailNotifications: true, - careerChecklist: [], - }, - }); - dashboardJobsState.recommendedJobs = [ - { - id: "remote-job", - jobTitle: "Remote Node", - company: "Globex", - location: "Global", - salary: "A combinar", - type: "Remoto", - level: "Sênior", - matchScore: 91, - tags: ["Node.js"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/remote", - source: "Gupy", - notes: "", - }, - { - id: "onsite-job", - jobTitle: "Onsite Node", - company: "ACME", - location: "São Paulo", - salary: "A combinar", - type: "Presencial", - level: "Sênior", - matchScore: 88, - tags: ["Node.js"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/onsite", - source: "LinkedIn", - notes: "", - }, + it("recalcula score em recommendedJobs quando matchScore = 0", () => { + defaultJobs({ + recommendedJobs: [ + { + id: "r1", + jobTitle: "Dev React", + company: "C", + location: "BR", + type: "full-time", + level: "jr", + tags: [], + rawPayload: {}, + matchScore: 0, + }, + ], + }); + mockUserData.userProfile.technologyExperiences = [ + { name: "react", years: 2 }, ]; + mockPathname = "/vagas"; + render(); + expect(screen.getByTestId("job-tab")).toBeInTheDocument(); + }); +}); + +describe("NewDashboardPage - toast e timers", () => { + it("mostra e limpa toast após 3s", async () => { + vi.useFakeTimers(); + defaultJobs({ + changeJobStatus: vi + .fn() + .mockResolvedValue({ id: "job-1", status: "applied" }), + }); + mockPathname = "/dashboard"; + render(); - renderPage("/vagas"); + fireEvent.click(screen.getByText("dash-status")); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId("toast")).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByDisplayValue("Somente remotas")).toBeInTheDocument(); + await act(async () => { + vi.advanceTimersByTime(3000); }); + expect(screen.queryByTestId("toast")).not.toBeInTheDocument(); + }); - expect(screen.getByText(/preferência ativa/i)).toBeInTheDocument(); - expect(screen.getByText("Remote Node")).toBeInTheDocument(); - expect(screen.queryByText("Onsite Node")).not.toBeInTheDocument(); + it("limpa timeout de checklist ao desmontar", () => { + mockPathname = "/"; + const { unmount } = render(); + fireEvent.click(screen.getByText("home-checklist")); + unmount(); + }); - fireEvent.change(screen.getByDisplayValue("Match (padrão)"), { - target: { value: "desc" }, + it("reutiliza timeout de checklist em mudanças consecutivas", () => { + vi.useFakeTimers(); + mockPathname = "/"; + render(); + fireEvent.click(screen.getByText("home-checklist")); + fireEvent.click(screen.getByText("home-checklist")); + act(() => { + vi.advanceTimersByTime(700); }); + }); +}); - expect(screen.queryByText(/preferência ativa/i)).not.toBeInTheDocument(); +describe("NewDashboardPage - handlers de perfil/preferências", () => { + beforeEach(() => { + mockPathname = "/perfil"; }); - it("aciona a busca e paginação de vagas", () => { - renderPage("/vagas"); + it("salva perfil com sucesso e mostra toast", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText("save-profile")); + }); + await waitFor(() => expect(mockSaveUserProfile).toHaveBeenCalled()); + expect(mockRefreshUser).toHaveBeenCalled(); + expect(screen.getByTestId("toast")).toHaveTextContent( + "Perfil atualizado com sucesso.", + ); + }); - fireEvent.change(screen.getByPlaceholderText(/buscar por cargo, empresa ou keywords/i), { - target: { value: "node.js" }, + it("trata erro ao salvar perfil", async () => { + mockSaveUserProfile.mockRejectedValueOnce(new Error("fail")); + render(); + await act(async () => { + fireEvent.click(screen.getByText("save-profile")); }); - fireEvent.click(screen.getByRole("button", { name: /buscar vagas/i })); - fireEvent.click(screen.getByRole("button", { name: /próxima página/i })); + await waitFor(() => expect(mockSaveUserProfile).toHaveBeenCalled()); + }); - expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( - ["node.js"], - {}, - 2, - 50, + it("salva preferências com sucesso", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText("save-prefs")); + }); + await waitFor(() => expect(mockSaveSearchPreferences).toHaveBeenCalled()); + expect(screen.getByTestId("toast")).toHaveTextContent( + "Preferências de busca atualizadas.", ); - expect(dashboardJobsState.changeRecommendationsPage).not.toHaveBeenCalled(); }); - it("mantém a ordenação por match ao trocar de página", () => { - renderPage("/vagas"); + it("trata erro ao salvar preferências", async () => { + mockSaveSearchPreferences.mockRejectedValueOnce(new Error("fail")); + render(); + await act(async () => { + fireEvent.click(screen.getByText("save-prefs")); + }); + await waitFor(() => expect(mockSaveSearchPreferences).toHaveBeenCalled()); + }); +}); + +describe("NewDashboardPage - filtros e busca", () => { + beforeEach(() => { + mockPathname = "/vagas"; + }); + + it("chama onSearchJobs quando clicado", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-search")); + }); + expect(mockRefreshRecommendations).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Object), + 1, + ); + }); - fireEvent.change(screen.getByDisplayValue("Match (padrão)"), { - target: { value: "desc" }, + it("trata erro em onSearchJobs", async () => { + mockRefreshRecommendations.mockRejectedValueOnce(new Error("fail")); + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-search")); }); - fireEvent.click(screen.getByRole("button", { name: /próxima página/i })); + }); - expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( - [], - { matchSort: "desc" }, + it("chama onPageChange", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-page")); + }); + expect(mockRefreshRecommendations).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Object), 2, - 50, + expect.any(Number), ); }); - it("mantém a ordem recebida do backend ao selecionar match", () => { - dashboardJobsState.recommendedJobs = [ - { - id: "job-low", - jobTitle: "Vaga menor match", - company: "ACME", - location: "Brasil", - salary: "A combinar", - type: "Remoto", - level: "Pleno", - matchScore: 45, - tags: ["React"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/low", - source: "LinkedIn", - notes: "", - rawPayload: { matchSource: "backend_profile" }, - }, - { - id: "job-high", - jobTitle: "Vaga maior match", - company: "Globex", - location: "Brasil", - salary: "A combinar", - type: "Remoto", - level: "Pleno", - matchScore: 98, - tags: ["React"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/high", - source: "Gupy", - notes: "", - rawPayload: { matchSource: "backend_profile" }, - }, - ]; + it("trata erro em onPageChange", async () => { + mockRefreshRecommendations.mockRejectedValueOnce(new Error("fail")); + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-page")); + }); + }); - renderPage("/vagas"); + it("chama onPageSizeChange", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-size")); + }); + expect(mockRefreshRecommendations).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Object), + 1, + 20, + ); + }); - fireEvent.change(screen.getByDisplayValue("Match (padrão)"), { - target: { value: "desc" }, + it("trata erro em onPageSizeChange", async () => { + mockRefreshRecommendations.mockRejectedValueOnce(new Error("fail")); + render(); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-size")); }); + }); - const rows = screen.getAllByRole("row").slice(1); - expect(rows[0]).toHaveTextContent("Vaga menor match"); - expect(rows[1]).toHaveTextContent("Vaga maior match"); + it("dispara busca automática com debounce ao mudar filtros", async () => { + vi.useFakeTimers(); + render(); + fireEvent.click(screen.getByText("set-search")); + fireEvent.click(screen.getByText("set-type")); + fireEvent.click(screen.getByText("set-level")); + fireEvent.click(screen.getByText("set-continent")); + fireEvent.click(screen.getByText("set-country")); + fireEvent.click(screen.getByText("set-sort")); + await act(async () => { + vi.advanceTimersByTime(600); + }); + expect(mockRefreshRecommendations).toHaveBeenCalled(); }); +}); - it("calcula o match das vagas com base nas tecnologias do perfil", () => { - dashboardJobsState.recommendedJobs = [ - { - id: "job-match", - jobTitle: "Frontend React Developer", - company: "ACME", - location: "Brasil", - salary: "A combinar", - type: "Remoto", - level: "Pleno", - matchScore: 70, - tags: ["React"], - posted: "Hoje", - status: "saved", - jobLink: "https://example.com/match", - source: "LinkedIn", - notes: "", - rawPayload: { - description: "Aplicação com React, TypeScript e design system.", - }, - }, - ]; +describe("NewDashboardPage - status / notas / jobs", () => { + it("altera status com job aberto (re-seleciona)", async () => { + const job = { + id: "job-1", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "ft", + level: "sr", + tags: [], + rawPayload: {}, + matchScore: 80, + } as any; + defaultJobs({ trackedJobs: [job] }); + mockChangeJobStatus.mockResolvedValueOnce({ id: "job-1", status: "applied" }); + mockPathname = "/dashboard"; + render(); + + fireEvent.click(screen.getByText("dash-open")); + await act(async () => { + fireEvent.click(screen.getByText("detail-status")); + }); + await waitFor(() => expect(mockChangeJobStatus).toHaveBeenCalled()); + }); - renderPage("/vagas"); + it("altera status sem modal aberto (job não selecionado)", async () => { + defaultJobs({ + recommendedJobs: [ + { + id: "rec-1", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "ft", + level: "sr", + tags: [], + rawPayload: {}, + matchScore: 70, + } as any, + ], + }); + mockChangeJobStatus.mockResolvedValueOnce({ id: "rec-1", status: "applied" }); + mockPathname = "/vagas"; + render(); + await act(async () => { + fireEvent.click(screen.getByText("rec-status")); + }); + await waitFor(() => expect(mockChangeJobStatus).toHaveBeenCalled()); + }); - expect(screen.getByText("98%")).toBeInTheDocument(); - expect(screen.getByText("98%")).toHaveAttribute( - "title", - "Tecnologias em comum: React, TypeScript", - ); + it("trata erro em changeJobStatus", async () => { + mockChangeJobStatus.mockRejectedValueOnce(new Error("fail")); + mockPathname = "/vagas"; + render(); + await act(async () => { + fireEvent.click(screen.getByText("rec-status")); + }); }); - it("envia filtros estruturados de localização para recomendações", () => { - renderPage("/vagas"); - - const [ - typeSelect, - levelSelect, - continentSelect, - countrySelect, - matchSortSelect, - ] = - screen.getAllByRole("combobox"); - - fireEvent.change(typeSelect, { target: { value: "Híbrido" } }); - fireEvent.change(levelSelect, { target: { value: "Sênior" } }); - fireEvent.change(continentSelect, { target: { value: "América do Sul" } }); - fireEvent.change(countrySelect, { target: { value: "Brasil" } }); - fireEvent.change(matchSortSelect, { target: { value: "desc" } }); - fireEvent.click(screen.getByRole("button", { name: /buscar vagas/i })); - - expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( - [], - { - type: "Híbrido", - model: "Híbrido", - level: "Sênior", - continent: "América do Sul", - country: "Brasil", - location: "Brasil", - matchSort: "desc", - }, - 1, - ); + it("propaga onNotesChange", async () => { + const job = { + id: "job-1", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "ft", + level: "sr", + tags: [], + rawPayload: {}, + matchScore: 80, + } as any; + defaultJobs({ trackedJobs: [job] }); + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-open")); + fireEvent.click(screen.getByText("detail-notes")); + expect(mockChangeJobNotesLocally).toHaveBeenCalledWith("job-1", "note"); }); - it("busca sem keywords quando o campo de busca está vazio", () => { - renderPage("/vagas"); + it("handleCloseJob persiste notas do job selecionado", async () => { + const job = { + id: "job-1", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "ft", + level: "sr", + tags: [], + rawPayload: {}, + matchScore: 80, + } as any; + defaultJobs({ trackedJobs: [job] }); + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-open")); + await act(async () => { + fireEvent.click(screen.getByText("detail-close")); + }); + expect(mockSaveJobNotes).toHaveBeenCalled(); + }); - fireEvent.click(screen.getByRole("button", { name: /buscar vagas/i })); + it("handleCloseJob não persiste quando não há job selecionado", async () => { + mockPathname = "/vagas"; + render(); + expect(mockSaveJobNotes).not.toHaveBeenCalled(); + }); - expect(dashboardJobsState.refreshRecommendations).toHaveBeenCalledWith( - [], - {}, - 1, - ); + it("handleCloseJob trata erro em saveJobNotes", async () => { + mockSaveJobNotes.mockRejectedValueOnce(new Error("fail")); + const job = { + id: "job-1", + jobTitle: "Dev", + company: "X", + location: "BR", + type: "ft", + level: "sr", + tags: [], + rawPayload: {}, + matchScore: 80, + } as any; + defaultJobs({ trackedJobs: [job] }); + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-open")); + await act(async () => { + fireEvent.click(screen.getByText("detail-close")); + }); }); +}); - it("atualiza o status da vaga aberta no modal", () => { - renderPage("/dashboard"); +describe("NewDashboardPage - adicionar vaga", () => { + it("abre modal via DashboardTab e adiciona vaga", async () => { + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-add")); + expect(screen.getByTestId("add-job-modal")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /frontend developer/i })); - fireEvent.change(screen.getByLabelText(/^status$/i), { - target: { value: "interviewing" }, + await act(async () => { + fireEvent.click(screen.getByText("add-submit")); }); - - expect(dashboardJobsState.changeJobStatus).toHaveBeenCalledWith( - "job-1", - "interviewing", + expect(mockAddTrackedJob).toHaveBeenCalled(); + expect(screen.getByTestId("toast")).toHaveTextContent( + "Vaga adicionada às suas oportunidades.", ); }); - it("renderiza a tela de perfil com formulário de avatar", () => { - renderPage("/perfil"); + it("fecha modal sem adicionar", () => { + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-add")); + fireEvent.click(screen.getByText("add-close")); + expect(screen.queryByTestId("add-job-modal")).not.toBeInTheDocument(); + }); - expect( - screen.getByRole("heading", { name: /informações gerais/i }), - ).toBeInTheDocument(); - expect(screen.getByPlaceholderText("https://...")).toBeInTheDocument(); + it("trata erro ao adicionar vaga", async () => { + mockAddTrackedJob.mockRejectedValueOnce(new Error("fail")); + mockPathname = "/dashboard"; + render(); + fireEvent.click(screen.getByText("dash-add")); + await act(async () => { + fireEvent.click(screen.getByText("add-submit")); + }); }); +}); - it("renderiza a tela de mentoria", () => { - renderPage("/mentoria"); +describe("NewDashboardPage - navegação HomeTab", () => { + it("navega para /vagas ao explorar vagas", () => { + render(); + fireEvent.click(screen.getByText("home-explore")); + expect(mockNavigate).toHaveBeenCalledWith("/vagas"); + }); +}); - expect( - screen.getByRole("heading", { name: /mentores disponíveis/i }), - ).toBeInTheDocument(); +describe("NewDashboardPage - preferências (efetivo do modelo)", () => { + it("aplica preferredModelFilter quando usuário não alterou filtros", () => { + mockPathname = "/vagas"; + mockUserData.searchPreferences = { + jobTypes: ["Remoto"], + careerChecklist: [], + }; + render(); + expect(screen.getByTestId("job-tab")).toBeInTheDocument(); }); - it("renderiza a central de ajuda", () => { - renderPage("/ajuda"); + it("não sobrescreve filterType após o usuário escolher manualmente", async () => { + mockPathname = "/vagas"; + mockUserData.searchPreferences = { + jobTypes: ["Remoto"], + careerChecklist: [], + }; + const { rerender } = render(); + fireEvent.click(screen.getByText("set-type")); + mockUserData.searchPreferences = { + jobTypes: [], + careerChecklist: [], + }; + rerender(); + expect(screen.getByTestId("job-tab")).toBeInTheDocument(); + }); - expect( - screen.getByRole("heading", { name: /perguntas frequentes & central de ajuda/i }), - ).toBeInTheDocument(); + it("não aplica preferredModelFilter enquanto isLoadingUserData = true", () => { + mockPathname = "/vagas"; + mockUserData.isLoadingUserData = true; + mockUserData.searchPreferences = { + jobTypes: ["Remoto"], + careerChecklist: [], + }; + render(); + expect(screen.getByTestId("job-tab")).toBeInTheDocument(); }); }); + +describe("NewDashboardPage - useEffect de busca automática - bordas", () => { + it("não dispara busca em seções que não são /vagas", async () => { + vi.useFakeTimers(); + mockPathname = "/dashboard"; + render(); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(mockRefreshRecommendations).not.toHaveBeenCalled(); + }); + + it("primeira execução em /vagas é pulada", async () => { + vi.useFakeTimers(); + mockPathname = "/vagas"; + render(); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(mockRefreshRecommendations).not.toHaveBeenCalled(); + }); +}); + +describe("NewDashboardPage - buildRecommendationSearch com todos os filtros", () => { + it("aplica todos os filtros na busca", async () => { + mockPathname = "/vagas"; + render(); + fireEvent.click(screen.getByText("set-search")); + fireEvent.click(screen.getByText("set-level")); + fireEvent.click(screen.getByText("set-continent")); + fireEvent.click(screen.getByText("set-country")); + fireEvent.click(screen.getByText("set-sort")); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-search")); + }); + expect(mockRefreshRecommendations).toHaveBeenCalledWith( + ["react"], + expect.objectContaining({ + level: "Junior", + continent: "América", + country: "Brasil", + location: "Brasil", + matchSort: "best", + }), + 1, + ); + }); +}); \ No newline at end of file diff --git a/frontend/tests/unit/new_dashboard/types.test.ts b/frontend/tests/unit/new_dashboard/types.test.ts new file mode 100644 index 00000000..3bf7f20e --- /dev/null +++ b/frontend/tests/unit/new_dashboard/types.test.ts @@ -0,0 +1,210 @@ +import * as dashboardTypes from "@/domains/new_dashboard/types"; +import { + JobLevelSchema, + JobSchema, + JobStatusSchema, + JobTypeSchema, + NewJobSchema, +} from "@/domains/new_dashboard/types/job"; +import { MentorSchema } from "@/domains/new_dashboard/types/mentor"; +import { + CareerChecklistSchema, + MessageSchema, + NotificationSchema, + SearchPreferencesSchema, + TechnologyExperienceSchema, + UserProfileSchema, +} from "@/domains/new_dashboard/types/user"; +import { describe, expect, it } from "vitest"; + +const validJob = { + id: "job-1", + jobTitle: "Backend Developer", + company: "Example Co", + location: "Remote", + salary: "A combinar", + type: "Remoto", + level: "Pleno", + matchScore: 85, + tags: ["Go", "PostgreSQL"], + posted: "Hoje", + status: "saved", + jobLink: "https://example.com/jobs/1", + source: "Example", + notes: "", +}; + +const validMentor = { + id: "mentor-1", + name: "Alex Mentor", + rating: 5, + completed: 12, + days: "Segunda a sexta", + hours: "09:00-17:00", + nextSessionDate: "Amanhã", + specialty: "Backend", + avatarColor: "#117744", + platform: "Google Meet", + platformUrl: "https://meet.google.com/abc-defg-hij", + agenda: "https://example.com/agenda", +}; + +describe("new_dashboard runtime types", () => { + it("re-exports public schemas from the types barrel", () => { + expect(dashboardTypes.JobSchema).toBe(JobSchema); + expect(dashboardTypes.JobStatusSchema).toBe(JobStatusSchema); + expect(dashboardTypes.JobTypeSchema).toBe(JobTypeSchema); + expect(dashboardTypes.JobLevelSchema).toBe(JobLevelSchema); + expect(dashboardTypes.NewJobSchema).toBe(NewJobSchema); + expect(dashboardTypes.MentorSchema).toBe(MentorSchema); + expect(dashboardTypes.UserProfileSchema).toBe(UserProfileSchema); + expect(dashboardTypes.SearchPreferencesSchema).toBe(SearchPreferencesSchema); + expect(dashboardTypes.TechnologyExperienceSchema).toBe( + TechnologyExperienceSchema, + ); + expect(dashboardTypes.CareerChecklistSchema).toBe(CareerChecklistSchema); + expect(dashboardTypes.NotificationSchema).toBe(NotificationSchema); + expect(dashboardTypes.MessageSchema).toBe(MessageSchema); + }); + + it("validates jobs, form input, enums, score bounds, and optional payload", () => { + expect(JobSchema.parse({ ...validJob, rawPayload: { sourceId: 7 } })).toMatchObject( + validJob, + ); + expect(JobSchema.parse(validJob).rawPayload).toBeUndefined(); + expect(JobSchema.safeParse({ ...validJob, matchScore: 101 }).success).toBe( + false, + ); + expect(JobTypeSchema.safeParse("Híbrido").success).toBe(true); + expect(JobTypeSchema.safeParse("Flexível").success).toBe(false); + expect(JobLevelSchema.safeParse("Sênior").success).toBe(true); + expect(JobStatusSchema.safeParse("interviewing").success).toBe(true); + + expect( + NewJobSchema.safeParse({ + jobTitle: "Dev", + company: "Example", + location: "", + salary: "", + type: "Remoto", + level: "Júnior", + tags: "Go, SQL", + source: "", + jobLink: "", + notes: "", + }).success, + ).toBe(true); + expect(NewJobSchema.safeParse({ ...validJob, jobTitle: "" }).success).toBe( + false, + ); + }); + + it("validates mentor limits, required agenda, platform and URLs", () => { + expect(MentorSchema.safeParse(validMentor).success).toBe(true); + expect(MentorSchema.safeParse({ ...validMentor, rating: 6 }).success).toBe( + false, + ); + expect( + MentorSchema.safeParse({ ...validMentor, platform: "Skype" }).success, + ).toBe(false); + expect( + MentorSchema.safeParse({ ...validMentor, platformUrl: "not-a-url" }) + .success, + ).toBe(false); + }); + + it("validates profile preferences and nested career checklist values", () => { + const profile = { + firstName: "Ada", + lastName: "Lovelace", + displayName: "Ada Lovelace", + username: "ada", + email: "ada@example.com", + avatarUrl: "", + phone: "+5511999999999", + level: "Sênior", + technologies: ["Go"], + technologyExperiences: [{ name: "Go", years: 3 }], + }; + const checklist = { + id: "month-1", + title: "Portfólio", + month: "Outubro", + items: [{ id: "item-1", label: "Publicar projeto", checked: false }], + }; + + expect(UserProfileSchema.safeParse(profile).success).toBe(true); + expect( + UserProfileSchema.safeParse({ ...profile, email: "inválido" }).success, + ).toBe(false); + expect(TechnologyExperienceSchema.safeParse({ name: "Go", years: 0 }).success).toBe( + true, + ); + expect( + TechnologyExperienceSchema.safeParse({ name: "Go", years: -1 }).success, + ).toBe(false); + expect(CareerChecklistSchema.safeParse(checklist).success).toBe(true); + expect( + CareerChecklistSchema.safeParse({ + ...checklist, + items: [{ ...checklist.items[0], checked: "no" }], + }).success, + ).toBe(false); + expect( + SearchPreferencesSchema.safeParse({ + keywords: ["go"], + searchLocation: "Brasil", + remoteOnly: false, + jobTypes: ["Remoto"], + emailNotifications: true, + careerChecklist: [checklist], + }).success, + ).toBe(true); + }); + + it("accepts supported notification and optional message variants", () => { + expect( + NotificationSchema.safeParse({ + id: "n-1", + text: "Nova vaga compatível", + type: "match", + date: "Hoje", + isRead: false, + }).success, + ).toBe(true); + expect( + NotificationSchema.safeParse({ + id: 1, + text: "Atualização", + type: "unknown", + date: "Hoje", + }).success, + ).toBe(false); + expect( + MessageSchema.safeParse({ + id: "m-1", + sender: "Mentor", + text: "Olá", + date: "Agora", + }).success, + ).toBe(true); + expect( + MessageSchema.safeParse({ + id: 2, + sender: "RH", + text: "Olá", + date: "Agora", + origin: "recruiter", + }).success, + ).toBe(true); + expect( + MessageSchema.safeParse({ + id: 2, + sender: "RH", + text: "Olá", + date: "Agora", + origin: "unknown", + }).success, + ).toBe(false); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/utils.test.ts b/frontend/tests/unit/new_dashboard/utils.test.ts index 1a7bb62d..12c08c43 100644 --- a/frontend/tests/unit/new_dashboard/utils.test.ts +++ b/frontend/tests/unit/new_dashboard/utils.test.ts @@ -1,14 +1,22 @@ +import type { Job, JobModelFilter, JobType } from "@/domains/new_dashboard/types"; import { - createJobFromForm, - getInitials, - splitTags, + createJobFromForm, + getInitials, + splitTags, } from "@/domains/new_dashboard/utils/helpers"; import { - getContinentFromLocation, - matchesContinent, - matchesCountry, - matchesLocationFilters, + getJobTypesFromModelFilter, + getModelFilterFromJobTypes, + modelFilterMatchesJob, + modelFilterToApiFilter, +} from "@/domains/new_dashboard/utils/jobModelFilters"; +import { + getContinentFromLocation, + matchesContinent, + matchesCountry, + matchesLocationFilters, } from "@/domains/new_dashboard/utils/locationFilters"; +import { parseSearchKeywords } from "@/domains/new_dashboard/utils/searchKeywords"; import { beforeEach, describe, expect, it, vi } from "vitest"; describe("new_dashboard utils", () => { @@ -85,4 +93,79 @@ describe("new_dashboard utils", () => { ), ).toBe(true); }); + + it("maps model filters to supported job types and back", () => { + expect(getJobTypesFromModelFilter("Todos")).toEqual([]); + expect(getJobTypesFromModelFilter("RemotoHibrido")).toEqual([ + "Remoto", + "Híbrido", + ]); + expect(getModelFilterFromJobTypes(["Híbrido", "Remoto", "Remoto"])).toBe( + "RemotoHibrido", + ); + expect(getModelFilterFromJobTypes(["Presencial", "Remoto"])).toBe( + "RemotoPresencial", + ); + expect(getModelFilterFromJobTypes(["Remoto"])).toBe("Remoto"); + expect(getModelFilterFromJobTypes([])).toBe("Todos"); + expect(getJobTypesFromModelFilter("not-a-filter" as JobModelFilter)).toEqual( + [], + ); + expect( + getModelFilterFromJobTypes(["Remoto", "Híbrido", "Presencial"] as JobType[]), + ).toBe("Todos"); + }); + + it("matches job model filters and serializes API filters", () => { + const job = { type: "Híbrido" } as Job; + + expect(modelFilterMatchesJob(job, "Todos")).toBe(true); + expect(modelFilterMatchesJob(job, "RemotoHibrido")).toBe(true); + expect(modelFilterMatchesJob(job, "Remoto")).toBe(false); + expect(modelFilterToApiFilter("Todos")).toEqual({}); + expect(modelFilterToApiFilter("RemotoHibrido")).toEqual({ + type: "Remoto,Híbrido", + model: "Remoto,Híbrido", + }); + expect(modelFilterToApiFilter("Presencial" as JobModelFilter)).toEqual({ + type: "Presencial", + model: "Presencial", + }); + }); + + it("detects unknown and global locations and combines country/continent", () => { + expect(getContinentFromLocation("Remote from an unknown region")).toBe( + "Global / Remoto", + ); + expect(getContinentFromLocation("Atlantis")).toBe("Desconhecido"); + expect(matchesCountry("Lisboa, Portugal", "Todos")).toBe(true); + expect(matchesContinent("Lisboa, Portugal", "Todos")).toBe(true); + expect(matchesContinent("Remote / Worldwide", "Global / Remoto")).toBe( + true, + ); + expect(matchesContinent("Lisboa, Portugal", "Global / Remoto")).toBe( + false, + ); + expect( + matchesLocationFilters( + { location: "São Paulo, Brasil" }, + "América do Sul", + "Brasil", + ), + ).toBe(true); + expect( + matchesLocationFilters( + { location: "Lisboa, Portugal" }, + "Europa", + "Brasil", + ), + ).toBe(false); + }); + + it("splits search keywords, trims whitespace and removes duplicates", () => { + expect(parseSearchKeywords(" Go, React TypeScript;\nNode.js Go ")).toEqual( + ["Go", "React", "TypeScript", "Node.js"], + ); + expect(parseSearchKeywords(" , ; \n ")).toEqual([]); + }); }); From f60a81dc2df8a6bc480df828eafb7940acf1b122 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Wed, 30 Sep 2026 22:30:56 -0300 Subject: [PATCH 2/2] feat(pavv-128): remover validador de commit de mensagem --- .github/workflows/ci.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80f00e0f..149ad273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,15 +22,6 @@ jobs: - name: Instalar dependencias (raiz) run: npm ci - - name: Validar mensagens de commit (commitlint) - # Backstop server-side: o hook local commit-msg pode ser pulado com `git commit --no-verify`, - # então o CI revalida todos os commits do PR antes de liberar o merge. - # Roda só em pull_request: commits de push para master/develop já chegaram via squash-merge - # do GitHub, cuja mensagem é o título do PR (padrão "[PAV-XXX] [Categoria] descrição", - # ver GUIA-LINEAR-GITHUB.md) — um formato diferente e fora do controle de quem contribui. - if: github.event_name == 'pull_request' - run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose - - name: Workaround npm optional deps (rollup linux) run: npm i --no-save --workspace=frontend @rollup/rollup-linux-x64-gnu