From 275ecafab03587e72ddb5fcad70faaa1ac56368c Mon Sep 17 00:00:00 2001 From: hltav Date: Wed, 7 Oct 2026 07:59:25 -0300 Subject: [PATCH] feat(PAV-124): implementa filtros avancados por familias --- .gitignore | 1 + BACKEND.md | 42 +- backend/src/lib/errors.ts | 2 + .../jobFilterOptions.controller.ts | 23 + .../jobs/controllers/searchJobs.controller.ts | 7 +- .../modules/jobs/filters/jobSearch.filter.ts | 4 +- .../jobs/parsers/familyQuery.parser.ts | 50 + .../jobs/parsers/jobSearchQuery.parser.ts | 5 +- .../jobs/repositories/jobSearch.repository.ts | 73 + .../jobs/services/searchJobs.service.ts | 188 +-- .../src/modules/jobs/types/jobSearch.types.ts | 3 +- backend/src/routes/jobs.routes.ts | 3 + backend/src/swagger.ts | 1322 ++++++++++++++++- .../routes/searchJobs.routes.test.ts | 115 +- backend/tests/unit/app.test.ts | 43 +- .../modules/jobs/familyQuery.parser.test.ts | 85 ++ .../modules/jobs/jobSearch.filter.test.ts | 2 +- .../modules/jobs/jobSearch.repository.test.ts | 152 ++ .../modules/jobs/searchJobs.service.test.ts | 199 +-- backend/tests/unit/swagger.test.ts | 23 + 20 files changed, 1894 insertions(+), 448 deletions(-) create mode 100644 backend/src/modules/jobs/controllers/jobFilterOptions.controller.ts create mode 100644 backend/src/modules/jobs/parsers/familyQuery.parser.ts create mode 100644 backend/src/modules/jobs/repositories/jobSearch.repository.ts create mode 100644 backend/tests/unit/modules/jobs/familyQuery.parser.test.ts create mode 100644 backend/tests/unit/modules/jobs/jobSearch.repository.test.ts diff --git a/.gitignore b/.gitignore index 59131956..98a2f316 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ tools/ npm-debug.log yarn-error.log yarn-debug.log +PAV-124_REPORT.md # Test coverage reports coverage/ diff --git a/BACKEND.md b/BACKEND.md index d2f7aa88..597023b3 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -58,7 +58,7 @@ Módulos principais: - `src/modules/users` — perfis e preferências do usuário (`UsersController`, `UsersService`). - `src/modules/savedJobs` — CRUD de vagas salvas (`SavedJobsController`, `SavedJobsService`). - `src/modules/notifications` — notificações do usuário autenticado. -- `src/modules/jobs` — busca, parsing de filtros, fallback pós-filtro e regras de matching/score de vagas. +- `src/modules/jobs` — busca, parsing de filtros, repository com consulta em lotes e regras de matching/score de vagas. - `src/modules/admin` — usuários admin, permissões, scrapers, auditoria, dashboard e observabilidade. - `src/modules/email` — envio de e-mails transacionais assíncronos (ver seção [Módulo de E-mail](#módulo-de-e-mail)). @@ -526,3 +526,43 @@ Response (200): --- Os exemplos acima são intencionais e servem como referência rápida para integrar o frontend ou scripts que consomem a API. + +## Filtros de famílias profissionais — PAV-124 + +`GET /api/v1/jobs/search` (também disponível em `/jobs/search`) mantém a autenticação por sessão e o envelope de sucesso com `jobs`, `total`, `page`, `limit`, `totalPages`, `hasNext`, `hasPrev` e `source`. + +O parâmetro `family` aceita um ID, IDs separados por vírgula, parâmetros repetidos ou ambos. Exemplos equivalentes: + +```text +?family=backend,fullstack +?family=fullstack,backend +?family=backend&family=fullstack +?family=fullstack,backend&family=backend +``` + +O parser central produz `families: ProfessionalFamily[]` ordenadas lexicograficamente, sem espaços externos, valores vazios ou duplicidades, e `familyMode: "primary" | "any"`. O limite de 13 é aplicado às famílias únicas após normalização. `family=backend,` é válido; `family=` e `family=,,,` retornam 400. IDs são sensíveis à caixa: labels (`Backend`, `Full Stack`, `Dados e IA`), aliases históricos e `other` não são públicos. + +As famílias são derivadas de `professionalTaxonomy.ts`, o módulo da PAV-123 já verificado contra `scraper-go/internal/taxonomy/families.json`: backend, frontend, fullstack, mobile, data, devops, platform, qa, security, product, product_design, software e leadership. Nenhuma nova taxonomia foi criada. + +- `familyMode=primary`: considera somente `classification.primaryFamily`. +- `familyMode=any` (default): considera principal e relacionadas. +- `familyMode` sem `family` é validado, mas não restringe a busca. +- Repetição de `familyMode`, modo vazio ou diferente dos dois valores retorna 400. +- Famílias usam OR entre si e AND com os outros grupos de filtros. +- `fullstack` não expande para backend/frontend; devops e platform são independentes. + +Exemplo: `?family=product,product_design&familyMode=primary&seniority=senior&model=remoto&country=Brasil&contract=clt`. Os parâmetros de modalidade existentes continuam sendo `model`/`type` (`model` prevalece), com os valores atuais `remoto`, `hibrido` e `presencial`. Esta tarefa não acrescenta `modality` ou um filtro `provider`, que não estavam implementados na busca auditada. Keywords, tecnologias, empresa, senioridade, nível, localização, contrato e aliases, paginação e ordenação continuam com seus parsers e predicados existentes. + +Erros de validação usam o envelope padrão `{ "code": "INVALID_JOB_FAMILY" | "INVALID_FAMILY_MODE", "message": "..." }`, status 400, antes de consultar vagas ou perfil. Falhas de infraestrutura preservam o envelope legado da busca `{ "message": "...", "error": "..." }`, status 500. + +`GET /api/v1/jobs/filters/options` retorna `taxonomyVersion`, as 13 famílias na ordem canônica com IDs/labels e os modos `any` (default) e `primary`. O endpoint mantém a autenticação de `/jobs`, não consulta vagas/contagens/scraper e envia `Cache-Control: public, max-age=3600` e ETag SHA-256 da versão/conteúdo. `If-None-Match` permite 304. + +### Consulta, compatibilidade e limites temporários + +A busca anterior já incluía principal e relacionadas; o default `any` conserva essa semântica. A validação estrita deixa de aceitar labels e listas explicitamente vazias, conforme o contrato novo. O repository verifica documentos persistidos em lotes de até 200 e aplica todos os filtros antes de contar e selecionar a página. Não há pós-filtro depois da paginação e não são carregados todos os documentos simultaneamente. + +Para impedir perdas com índices estruturados incompletos, buscas filtradas usam o índice global de IDs ou a resolução existente de keywords. O campo diagnóstico `source` passa a usar `:verified_batches` nessas buscas. Isso pode recuperar vagas que os índices anteriores omitiam e mudar a ordem incidental do conjunto de candidatos; não existe ordenação cronológica garantida por Sets do Valkey. A ordem das famílias da request não altera o predicado nem o caminho de consulta. Ordenação explícita por match continua global, com desempate pela ordem dos candidatos e apenas IDs/scores retidos; o cálculo de score existente não foi alterado. A busca simples sem filtros/ordenação mantém sua priorização por perfil e hidratação atuais. + +Custo temporário: O(N) documentos candidatos por busca filtrada para obter total exato; IDs permanecem em memória. Ordenação por match conserva no máximo `offset + limit` IDs/scores e hidrata novamente a página escolhida. Valkey não oferece snapshot entre essas leituras: expiração/reclassificação concorrente pode mudar documentos durante uma busca. A busca simples legada ainda estima total descontando apenas órfãos observados na hidratação da página; esse comportamento preexistente foi preservado. + +Ficam para a próxima PAV: índices separados de famílias principais/relacionadas, reconstrução de índices, cache keys finais, invalidação por reclassificação e evolução de match score. Não houve mudança no Processor Go, autenticação, autorização ou rate limit (a busca não tinha limitador próprio; os limitadores de autenticação permanecem). diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts index 9b3d0712..f2346125 100644 --- a/backend/src/lib/errors.ts +++ b/backend/src/lib/errors.ts @@ -1,6 +1,8 @@ import { ZodError } from "zod"; export type ErrorCode = + | "INVALID_JOB_FAMILY" + | "INVALID_FAMILY_MODE" | "VALIDATION_ERROR" | "UNAUTHORIZED" | "FORBIDDEN" diff --git a/backend/src/modules/jobs/controllers/jobFilterOptions.controller.ts b/backend/src/modules/jobs/controllers/jobFilterOptions.controller.ts new file mode 100644 index 00000000..d25792cc --- /dev/null +++ b/backend/src/modules/jobs/controllers/jobFilterOptions.controller.ts @@ -0,0 +1,23 @@ +import { createHash } from "node:crypto"; +import type { Request, Response } from "express"; +import { + professionalFamilies, + taxonomyVersion, +} from "../types/professionalTaxonomy"; + +export const jobFilterOptions = { + taxonomyVersion, + families: professionalFamilies, + familyModes: [ + { id: "any", label: "Principal ou relacionada", default: true }, + { id: "primary", label: "Somente família principal", default: false }, + ], +}; +const etag = `"${createHash("sha256").update(JSON.stringify(jobFilterOptions)).digest("hex")}"`; + +export function jobFilterOptionsController(_req: Request, res: Response): void { + res.set("Cache-Control", "public, max-age=3600"); + res.set("ETag", etag); + // Express handles If-None-Match freshness and 304 on res.json. + res.json(jobFilterOptions); +} diff --git a/backend/src/modules/jobs/controllers/searchJobs.controller.ts b/backend/src/modules/jobs/controllers/searchJobs.controller.ts index 751f9908..6bcec290 100644 --- a/backend/src/modules/jobs/controllers/searchJobs.controller.ts +++ b/backend/src/modules/jobs/controllers/searchJobs.controller.ts @@ -1,3 +1,4 @@ +import { isAppError } from "../../../lib/errors"; import type { NextFunction, Request, Response } from "express"; import { logWarn } from "../../../logger"; import { jobSearchesTotal } from "../../../metrics/metrics"; @@ -18,7 +19,7 @@ function hasKeywords(query: Request["query"]): boolean { export async function searchJobsController( req: Request, res: Response, - _next: NextFunction, + next: NextFunction, ): Promise { jobSearchesTotal.inc({ has_keywords: hasKeywords(req.query) ? "true" : "false" }); @@ -30,6 +31,10 @@ export async function searchJobsController( res.json(result); } catch (error) { + if (isAppError(error)) { + next(error); + return; + } logWarn("Erro ao buscar vagas no ecossistema Valkey", { error: (error as Error).message, }); diff --git a/backend/src/modules/jobs/filters/jobSearch.filter.ts b/backend/src/modules/jobs/filters/jobSearch.filter.ts index f972e227..4642ee0f 100644 --- a/backend/src/modules/jobs/filters/jobSearch.filter.ts +++ b/backend/src/modules/jobs/filters/jobSearch.filter.ts @@ -304,7 +304,7 @@ export function filterJobs( const city = normalizeComparable(filters.city); const contract = normalizeComparable(filters.contract); const types = filters.type.map(normalizeComparable); - const families = filters.family.map(normalizeComparable); + const families = filters.families.map(normalizeComparable); const technologies = filters.technology.map(normalizeComparable); const companies = filters.company.map(normalizeComparable); @@ -332,7 +332,7 @@ export function filterJobs( const classification = candidate.classification; const classifiedFamilies = [ classification?.primaryFamily, - ...(classification?.relatedFamilies ?? []), + ...(filters.familyMode === "any" ? classification?.relatedFamilies ?? [] : []), ] .filter(Boolean) .map((value) => normalizeComparable(String(value))); diff --git a/backend/src/modules/jobs/parsers/familyQuery.parser.ts b/backend/src/modules/jobs/parsers/familyQuery.parser.ts new file mode 100644 index 00000000..871b762b --- /dev/null +++ b/backend/src/modules/jobs/parsers/familyQuery.parser.ts @@ -0,0 +1,50 @@ +import { AppError } from "../../../lib/errors"; +import type { ParsedJobSearchQuery } from "../types/jobSearch.types"; +import { + isPublicFamily, + professionalFamilies, +} from "../types/professionalTaxonomy"; + +export function parseFamilyQuery( + query: Record, +): Pick { + const mode = query.familyMode; + if (mode !== undefined && mode !== "any" && mode !== "primary") { + throw new AppError( + "INVALID_FAMILY_MODE", + "familyMode deve ser any ou primary.", + 400, + ); + } + if (query.family === undefined) + return { families: [], familyMode: mode ?? "any" }; + + const raw = Array.isArray(query.family) ? query.family : [query.family]; + if (raw.some((value) => typeof value !== "string")) { + throw new AppError( + "INVALID_JOB_FAMILY", + "family deve conter IDs canônicos de famílias.", + 400, + ); + } + const values = [ + ...new Set( + (raw as string[]) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean), + ), + ].sort(); + if ( + values.length === 0 || + values.length > professionalFamilies.length || + values.some((value) => !isPublicFamily(value)) + ) { + throw new AppError( + "INVALID_JOB_FAMILY", + "Informe de 1 a 13 famílias públicas válidas.", + 400, + ); + } + return { families: values.filter(isPublicFamily), familyMode: mode ?? "any" }; +} diff --git a/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts b/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts index ba711063..f272aac0 100644 --- a/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts +++ b/backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts @@ -1,3 +1,4 @@ +import { parseFamilyQuery } from "./familyQuery.parser"; import type { Request } from "express"; import type { ParsedJobSearchQuery } from "../types/jobSearch.types"; @@ -27,7 +28,7 @@ export function parseJobSearchQuery( return { keywords: queryValues(query.keywords), - family: queryValues(query.family), + ...parseFamilyQuery(query), technology: queryValues(query.technology), company: queryValues(query.company), type, @@ -61,7 +62,7 @@ export function hasStructuredFilters(filters: ParsedJobSearchQuery): boolean { filters.continent || filters.state || filters.city || - filters.family.length > 0 || + filters.families.length > 0 || filters.technology.length > 0 || filters.seniority || filters.type.length > 0 || diff --git a/backend/src/modules/jobs/repositories/jobSearch.repository.ts b/backend/src/modules/jobs/repositories/jobSearch.repository.ts new file mode 100644 index 00000000..de57e4d4 --- /dev/null +++ b/backend/src/modules/jobs/repositories/jobSearch.repository.ts @@ -0,0 +1,73 @@ +import { + cacheAbsoluteSMembers, + cacheGetJobsByIds, + cacheSearchKeywords, +} from "../../../lib/cache"; +import type { PaginationParams } from "../../../lib/pagination"; +import { filterJobs } from "../filters/jobSearch.filter"; +import type { ParsedJobSearchQuery } from "../types/jobSearch.types"; + +const BATCH_SIZE = 200; +type RankedJob = { id: string; rank: number; score: number }; + +/** Until classification indexes exist, verify persisted documents in bounded batches. + * Keep only the requested page (or its top-ranked prefix), never all job documents. + * Keyword resolution retains the existing union/alias semantics. + */ +export class JobSearchRepository { + async search( + filters: ParsedJobSearchQuery, + pagination: PaginationParams, + enrich?: (jobs: unknown[]) => Promise, + ): Promise<{ jobs: unknown[]; total: number }> { + const ids = [ + ...new Set( + filters.keywords.length + ? await cacheSearchKeywords(filters.keywords) + : await cacheAbsoluteSMembers("scraper:jobs:index"), + ), + ]; + const offset = (pagination.page - 1) * pagination.limit; + const capacity = Math.min(ids.length, offset + pagination.limit); + let total = 0; + const page: unknown[] = []; + let ranked: RankedJob[] = []; + const compare = (a: RankedJob, b: RankedJob) => + (filters.matchSort === "asc" ? a.score - b.score : b.score - a.score) || + a.rank - b.rank; + + for (let cursor = 0; cursor < ids.length; cursor += BATCH_SIZE) { + const matches = filterJobs( + await cacheGetJobsByIds(ids.slice(cursor, cursor + BATCH_SIZE)), + filters, + ); + if (filters.matchSort && enrich) { + const jobs = await enrich(matches); + const candidates = jobs.map((job, index) => ({ + id: String((job as { id: string }).id), + rank: total + index, + score: (job as { matchScore?: number }).matchScore ?? 0, + })); + ranked = [...ranked, ...candidates].sort(compare).slice(0, capacity); + } else { + for (const job of matches) { + if (total >= offset && page.length < pagination.limit) page.push(job); + total++; + } + continue; + } + total += matches.length; + } + return { + jobs: + filters.matchSort && enrich + ? await cacheGetJobsByIds( + ranked + .slice(offset, offset + pagination.limit) + .map((item) => item.id), + ) + : page, + total, + }; + } +} diff --git a/backend/src/modules/jobs/services/searchJobs.service.ts b/backend/src/modules/jobs/services/searchJobs.service.ts index c08cf3e8..dd4fe5d3 100644 --- a/backend/src/modules/jobs/services/searchJobs.service.ts +++ b/backend/src/modules/jobs/services/searchJobs.service.ts @@ -1,23 +1,22 @@ +import { JobSearchRepository } from "../repositories/jobSearch.repository"; import { - cacheAbsoluteSMembers, - cacheGetJobsByIds, - cacheGetJobsByIdsDetailed, - cacheRemoveJobIndexIds, - cacheSearchJobIds, - cacheSearchKeywords, + cacheAbsoluteSMembers, + cacheGetJobsByIdsDetailed, + cacheRemoveJobIndexIds, + cacheSearchKeywords, } from "../../../lib/cache"; import { paginate, parsePagination } from "../../../lib/pagination"; import { logWarn } from "../../../logger"; -import { filterJobs, sortJobsByMatch } from "../filters/jobSearch.filter"; +import { sortJobsByMatch } from "../filters/jobSearch.filter"; import { - hasPostOnlyFilters, - hasStructuredFilters, - parseJobSearchQuery, + hasPostOnlyFilters, + hasStructuredFilters, + parseJobSearchQuery, } from "../parsers/jobSearchQuery.parser"; import type { - MatchTechnology, - SearchJobsInput, - SearchJobsResult, + MatchTechnology, + SearchJobsInput, + SearchJobsResult, } from "../types/jobSearch.types"; import type { MatchableJob } from "./jobMatch.service"; import { JobProfileMatchService } from "./jobProfileMatch.service"; @@ -38,7 +37,6 @@ async function legacyResolveIds( }; } - const MAX_HYDRATION_WINDOWS = 10; async function removeOrphanIds(missingIds: string[]): Promise { @@ -57,7 +55,10 @@ async function removeOrphanIds(missingIds: string[]): Promise { async function hydrateIndexPage( ids: string[], { page, limit }: ReturnType, -): Promise<{ jobs: unknown[]; meta: ReturnType["pagination"] }> { +): Promise<{ + jobs: unknown[]; + meta: ReturnType["pagination"]; +}> { const jobs: unknown[] = []; const missingIds: string[] = []; let cursor = (page - 1) * limit; @@ -153,6 +154,7 @@ function toSearchResult( export class SearchJobsService { constructor( private readonly profileMatchService = new JobProfileMatchService(), + private readonly repository = new JobSearchRepository(), ) {} async execute(input: SearchJobsInput): Promise { @@ -168,43 +170,36 @@ export class SearchJobsService { ? `valkey_filtered_by_keywords:${filters.keywords.join("+")}` : "valkey_global_index"; - if (hasFilters) { - ids = await cacheSearchJobIds({ - keywords: filters.keywords, - family: filters.family, - technology: filters.technology, - seniority: filters.seniority, - level: filters.level, - location: filters.location, - continent: filters.continent, - country: filters.country, - state: filters.state, - city: filters.city, - type: filters.type, - model: filters.type, - contract: filters.contract, - }); - source = `${source}:structured_indexes`; - - if (ids.length === 0) { - return await this.searchWithPostFilterFallback( - filters, - pagination, - matchTechnologies, - input.userId, - `${source}:legacy_post_filter_fallback`, - ); - } - - const indexedJobs = await cacheGetJobsByIds(ids); - const filteredJobs = filterJobs(indexedJobs, filters); - return await this.paginateFilteredJobs( - filteredJobs, - filters.matchSort, + if (hasFilters || hasPostOnlyFilters(filters) || filters.matchSort) { + const result = await this.repository.search( + filters, pagination, - matchTechnologies, + filters.matchSort + ? async (jobs) => + this.profileMatchService.enrich( + input.userId, + jobs as MatchableJob[], + matchTechnologies, + { notifyHighMatches: false }, + ) + : undefined, + ); + const jobs = await this.profileMatchService.enrich( input.userId, - `${source}:verified`, + result.jobs as MatchableJob[], + matchTechnologies, + ); + const totalPages = Math.ceil(result.total / pagination.limit); + return toSearchResult( + jobs, + { + ...pagination, + total: result.total, + totalPages, + hasNext: pagination.page < totalPages, + hasPrev: pagination.page > 1, + }, + `${source}:verified_batches${filters.matchSort ? `:match_sorted_${filters.matchSort}` : ""}`, ); } @@ -212,37 +207,6 @@ export class SearchJobsService { ids = legacy.ids; source = legacy.source; - if (hasPostOnlyFilters(filters)) { - const legacyJobs = await cacheGetJobsByIds(ids); - return await this.paginateFilteredJobs( - filterJobs(legacyJobs, filters), - filters.matchSort, - pagination, - matchTechnologies, - input.userId, - `${source}:post_filter`, - ); - } - - if (filters.matchSort) { - const allJobs = await cacheGetJobsByIds(ids); - const matchedJobs = await this.profileMatchService.enrich( - input.userId, - allJobs as MatchableJob[], - matchTechnologies, - { notifyHighMatches: false }, - ); - const sortedJobs = sortJobsByMatch(matchedJobs, filters.matchSort); - const { data: jobs, pagination: meta } = paginate(sortedJobs, pagination); - await this.profileMatchService.enrich( - input.userId, - jobs as MatchableJob[], - matchTechnologies, - ); - - return toSearchResult(jobs, meta, `${source}:match_sorted_${filters.matchSort}`); - } - const relevance = await orderIdsByProfileRelevance(ids, matchTechnologies); const { jobs: pageJobs, meta } = await hydrateIndexPage( relevance.ids, @@ -256,71 +220,13 @@ export class SearchJobsService { if (relevance.matchedIds === 0) { return toSearchResult(jobs, meta, source); - } return toSearchResult( + } + return toSearchResult( sortJobsByMatch(jobs, "desc"), meta, `${source}:profile_ranked`, ); } - - private async searchWithPostFilterFallback( - filters: ReturnType, - pagination: ReturnType, - matchTechnologies: Parameters[2], - userId: string | undefined, - source: string, - ): Promise { - const legacy = await legacyResolveIds(filters.keywords); - const legacyJobs = await cacheGetJobsByIds(legacy.ids); - const filteredJobs = filterJobs(legacyJobs, filters); - - return await this.paginateFilteredJobs( - filteredJobs, - filters.matchSort, - pagination, - matchTechnologies, - userId, - source, - ); - } - - private async paginateFilteredJobs( - jobs: unknown[], - matchSort: "asc" | "desc" | null, - pagination: ReturnType, - matchTechnologies: Parameters[2], - userId: string | undefined, - source: string, - ): Promise { - if (matchSort) { - const matchedJobs = await this.profileMatchService.enrich( - userId, - jobs as MatchableJob[], - matchTechnologies, - { notifyHighMatches: false }, - ); - const { data: pageJobs, pagination: meta } = paginate( - sortJobsByMatch(matchedJobs, matchSort), - pagination, - ); - await this.profileMatchService.enrich( - userId, - pageJobs as MatchableJob[], - matchTechnologies, - ); - - return toSearchResult(pageJobs, meta, source); - } - - const { data: pageJobs, pagination: meta } = paginate(jobs, pagination); - const enrichedJobs = await this.profileMatchService.enrich( - userId, - pageJobs as MatchableJob[], - matchTechnologies, - ); - - return toSearchResult(enrichedJobs, meta, source); - } } export const searchJobsService = new SearchJobsService(); diff --git a/backend/src/modules/jobs/types/jobSearch.types.ts b/backend/src/modules/jobs/types/jobSearch.types.ts index 18f83e54..9ffb894b 100644 --- a/backend/src/modules/jobs/types/jobSearch.types.ts +++ b/backend/src/modules/jobs/types/jobSearch.types.ts @@ -25,7 +25,8 @@ export type MatchSort = "asc" | "desc" | null; export type ParsedJobSearchQuery = { keywords: string[]; - family: string[]; + families: ProfessionalFamily[]; + familyMode: "primary" | "any"; technology: string[]; company: string[]; type: string[]; diff --git a/backend/src/routes/jobs.routes.ts b/backend/src/routes/jobs.routes.ts index bf34dceb..a1100763 100644 --- a/backend/src/routes/jobs.routes.ts +++ b/backend/src/routes/jobs.routes.ts @@ -1,6 +1,9 @@ +import { jobFilterOptionsController } from "../modules/jobs/controllers/jobFilterOptions.controller"; import { Router } from "express"; import { searchJobsController } from "../modules/jobs/controllers/searchJobs.controller"; export const jobsRoutes = Router(); jobsRoutes.get("/search", searchJobsController); + +jobsRoutes.get("/filters/options", jobFilterOptionsController); diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index ba542663..4d898108 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1,5 +1,7 @@ import path from "path"; import swaggerJsdoc from "swagger-jsdoc"; +import { jobFilterOptions } from "./modules/jobs/controllers/jobFilterOptions.controller"; +import { professionalFamilies } from "./modules/jobs/types/professionalTaxonomy"; const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); const response = (name: string) => ({ $ref: `#/components/responses/${name}` }); @@ -26,109 +28,1275 @@ const options: swaggerJsdoc.Options = { info: { title: "Candidate API", version: "1.0.0", - description: "Contrato HTTP da Candidate. Todos os paths usam o prefixo /api/v1.", + description: + "Contrato HTTP da Candidate. Todos os paths usam o prefixo /api/v1.", }, servers: [{ url: "/api/v1", description: "API v1" }], - tags: ["System", "Auth", "Users", "Jobs", "Saved jobs", "Notifications", "Keywords", "Admin"].map((name) => ({ name })), + tags: [ + "System", + "Auth", + "Users", + "Jobs", + "Saved jobs", + "Notifications", + "Keywords", + "Admin", + ].map((name) => ({ name })), components: { securitySchemes: { - cookieAuth: { type: "apiKey", in: "cookie", name: "candidate_session", description: "Sessão criada no login." }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "candidate_session", + description: "Sessão criada no login.", + }, }, schemas: { - Error: { type: "object", required: ["code", "message"], properties: { code: { type: "string", example: "UNAUTHORIZED" }, message: { type: "string", example: "Autenticação necessária." } } }, - UserProfile: { type: "object", required: ["id", "email"], properties: { id: { type: "string", format: "uuid" }, email: { type: "string", format: "email", example: "ana@exemplo.com" }, displayName: { type: "string", nullable: true, example: "Ana Souza" }, firstName: { type: "string", nullable: true }, lastName: { type: "string", nullable: true }, username: { type: "string", nullable: true }, avatarUrl: { type: "string", format: "uri", nullable: true }, technologies: { type: "array", items: { type: "string" } }, level: { type: "string", nullable: true } } }, - Session: { type: "object", required: ["userId", "role"], properties: { userId: { type: "string", format: "uuid" }, role: { type: "string", enum: ["user", "support", "admin", "super_admin"] } } }, - AuthResponse: { type: "object", required: ["user", "session"], properties: { user: ref("UserProfile"), session: ref("Session") } }, - LoginRequest: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email", example: "ana@exemplo.com" }, password: { type: "string", format: "password", minLength: 1, example: "senha-segura" } } }, - RegisterRequest: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string", format: "password", minLength: 8, maxLength: 128 }, name: { type: "string", maxLength: 100, example: "Ana Souza" }, phone: { type: "string", example: "+55 11 99999-9999" }, cpf: { type: "string", example: "123.456.789-09" }, technologies: { type: "array", items: { type: "string" }, example: ["React", "TypeScript"] }, level: { type: "string", example: "Pleno" } } }, - ProfileUpdateRequest: { type: "object", properties: { displayName: { type: "string", nullable: true }, firstName: { type: "string", nullable: true }, lastName: { type: "string", nullable: true }, username: { type: "string", pattern: "^[a-z0-9_]+$" }, avatarUrl: { type: "string", format: "uri", nullable: true }, phone: { type: "string", nullable: true }, cpf: { type: "string", nullable: true }, technologies: { type: "array", maxItems: 30, items: { type: "string" } }, technologyExperiences: { type: "array", items: { type: "object", properties: { name: { type: "string" }, years: { type: "number", minimum: 0, maximum: 50 } } } }, level: { type: "string", nullable: true } } }, - Preferences: { type: "object", properties: { keywords: { type: "array", items: { type: "string" }, example: ["react", "node"] }, searchLocation: { type: "string", nullable: true }, searchLanguage: { type: "string", minLength: 2, maxLength: 2 }, remoteOnly: { type: "boolean" }, jobTypes: { type: "array", items: { type: "string", enum: ["Remoto", "Híbrido", "Presencial"] } }, emailNotifications: { type: "boolean" }, careerChecklist: { type: "array", items: { type: "object", additionalProperties: true } } } }, - Job: { type: "object", required: ["id", "jobTitle", "company", "jobLink"], properties: { id: { type: "string", example: "job-123" }, jobTitle: { type: "string", example: "Desenvolvedor Backend" }, company: { type: "string", example: "Candidate" }, location: { type: "string", example: "Remoto" }, jobLink: { type: "string", format: "uri", example: "https://empresa.exemplo/vagas/123" }, source: { type: "string", example: "LinkedIn" }, keyword: { type: "string", example: "node" } } }, - JobSearchResponse: { type: "object", required: ["jobs"], properties: { jobs: { type: "array", items: ref("Job") }, total: { type: "integer", example: 1 }, source: { type: "string", example: "valkey_filtered_by_keywords" } } }, - SavedJobRequest: { type: "object", required: ["jobLink"], properties: { jobLink: { type: "string", format: "uri" }, jobTitle: { type: "string" }, company: { type: "string" }, location: { type: "string" }, source: { type: "string" }, keyword: { type: "string" }, status: { type: "string", enum: ["saved", "applied", "interviewing", "rejected", "accepted"] }, appliedAt: { type: "string", format: "date-time" }, notes: { type: "string" } } }, - SavedJob: { allOf: [ref("SavedJobRequest"), { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" }, createdAt: { type: "string", format: "date-time" }, updatedAt: { type: "string", format: "date-time" } } }] }, - ApplicationEvent: { type: "object", properties: { id: { type: "string", format: "uuid" }, type: { type: "string", example: "status_changed" }, fromStatus: { type: "string", nullable: true }, toStatus: { type: "string" }, createdAt: { type: "string", format: "date-time" } } }, - Notification: { type: "object", required: ["id", "channel", "message", "createdAt"], properties: { id: { type: "string", format: "uuid" }, channel: { type: "string", enum: ["notification", "message"] }, type: { type: "string" }, title: { type: "string" }, message: { type: "string" }, readAt: { type: "string", format: "date-time", nullable: true }, createdAt: { type: "string", format: "date-time" }, entityType: { type: "string", nullable: true }, entityId: { type: "string", nullable: true } } }, - NotificationListResponse: { type: "object", required: ["notifications", "unreadCount"], properties: { notifications: { type: "array", items: ref("Notification") }, unreadCount: { type: "integer", minimum: 0, example: 1 } } }, - MutationResult: { type: "object", properties: { ok: { type: "boolean", example: true }, updated: { type: "integer", example: 1 }, deleted: { type: "integer", example: 1 } } }, - AdminUser: { type: "object", properties: { id: { type: "string", format: "uuid" }, email: { type: "string", format: "email" }, role: { type: "string" }, isBlocked: { type: "boolean" } } }, + JobFilterOptions: { + type: "object", + required: ["taxonomyVersion", "families", "familyModes"], + properties: { + taxonomyVersion: { type: "string", example: "v1" }, + families: { + type: "array", + minItems: 13, + maxItems: 13, + items: { + type: "object", + required: ["id", "label"], + properties: { + id: { + type: "string", + enum: professionalFamilies.map((f) => f.id), + }, + label: { type: "string" }, + }, + }, + }, + familyModes: { + type: "array", + items: { + type: "object", + required: ["id", "label", "default"], + properties: { + id: { type: "string", enum: ["any", "primary"] }, + label: { type: "string" }, + default: { type: "boolean" }, + }, + }, + }, + }, + }, + Error: { + type: "object", + required: ["code", "message"], + properties: { + code: { type: "string", example: "UNAUTHORIZED" }, + message: { type: "string", example: "Autenticação necessária." }, + }, + }, + UserProfile: { + type: "object", + required: ["id", "email"], + properties: { + id: { type: "string", format: "uuid" }, + email: { + type: "string", + format: "email", + example: "ana@exemplo.com", + }, + displayName: { + type: "string", + nullable: true, + example: "Ana Souza", + }, + firstName: { type: "string", nullable: true }, + lastName: { type: "string", nullable: true }, + username: { type: "string", nullable: true }, + avatarUrl: { type: "string", format: "uri", nullable: true }, + technologies: { type: "array", items: { type: "string" } }, + level: { type: "string", nullable: true }, + }, + }, + Session: { + type: "object", + required: ["userId", "role"], + properties: { + userId: { type: "string", format: "uuid" }, + role: { + type: "string", + enum: ["user", "support", "admin", "super_admin"], + }, + }, + }, + AuthResponse: { + type: "object", + required: ["user", "session"], + properties: { user: ref("UserProfile"), session: ref("Session") }, + }, + LoginRequest: { + type: "object", + required: ["email", "password"], + properties: { + email: { + type: "string", + format: "email", + example: "ana@exemplo.com", + }, + password: { + type: "string", + format: "password", + minLength: 1, + example: "senha-segura", + }, + }, + }, + RegisterRequest: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { + type: "string", + format: "password", + minLength: 8, + maxLength: 128, + }, + name: { type: "string", maxLength: 100, example: "Ana Souza" }, + phone: { type: "string", example: "+55 11 99999-9999" }, + cpf: { type: "string", example: "123.456.789-09" }, + technologies: { + type: "array", + items: { type: "string" }, + example: ["React", "TypeScript"], + }, + level: { type: "string", example: "Pleno" }, + }, + }, + ProfileUpdateRequest: { + type: "object", + properties: { + displayName: { type: "string", nullable: true }, + firstName: { type: "string", nullable: true }, + lastName: { type: "string", nullable: true }, + username: { type: "string", pattern: "^[a-z0-9_]+$" }, + avatarUrl: { type: "string", format: "uri", nullable: true }, + phone: { type: "string", nullable: true }, + cpf: { type: "string", nullable: true }, + technologies: { + type: "array", + maxItems: 30, + items: { type: "string" }, + }, + technologyExperiences: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + years: { type: "number", minimum: 0, maximum: 50 }, + }, + }, + }, + level: { type: "string", nullable: true }, + }, + }, + Preferences: { + type: "object", + properties: { + keywords: { + type: "array", + items: { type: "string" }, + example: ["react", "node"], + }, + searchLocation: { type: "string", nullable: true }, + searchLanguage: { type: "string", minLength: 2, maxLength: 2 }, + remoteOnly: { type: "boolean" }, + jobTypes: { + type: "array", + items: { + type: "string", + enum: ["Remoto", "Híbrido", "Presencial"], + }, + }, + emailNotifications: { type: "boolean" }, + careerChecklist: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + }, + }, + Job: { + type: "object", + required: ["id", "jobTitle", "company", "jobLink"], + properties: { + id: { type: "string", example: "job-123" }, + jobTitle: { type: "string", example: "Desenvolvedor Backend" }, + company: { type: "string", example: "Candidate" }, + location: { type: "string", example: "Remoto" }, + jobLink: { + type: "string", + format: "uri", + example: "https://empresa.exemplo/vagas/123", + }, + source: { type: "string", example: "LinkedIn" }, + keyword: { type: "string", example: "node" }, + }, + }, + JobSearchResponse: { + type: "object", + required: [ + "jobs", + "total", + "page", + "limit", + "totalPages", + "hasNext", + "hasPrev", + "source", + ], + properties: { + jobs: { type: "array", items: ref("Job") }, + total: { type: "integer", example: 1 }, + page: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1, maximum: 100 }, + totalPages: { type: "integer", minimum: 0 }, + hasNext: { type: "boolean" }, + hasPrev: { type: "boolean" }, + source: { type: "string", example: "valkey_filtered_by_keywords" }, + }, + }, + SavedJobRequest: { + type: "object", + required: ["jobLink"], + properties: { + jobLink: { type: "string", format: "uri" }, + jobTitle: { type: "string" }, + company: { type: "string" }, + location: { type: "string" }, + source: { type: "string" }, + keyword: { type: "string" }, + status: { + type: "string", + enum: [ + "saved", + "applied", + "interviewing", + "rejected", + "accepted", + ], + }, + appliedAt: { type: "string", format: "date-time" }, + notes: { type: "string" }, + }, + }, + SavedJob: { + allOf: [ + ref("SavedJobRequest"), + { + type: "object", + required: ["id"], + properties: { + id: { type: "string", format: "uuid" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + ], + }, + ApplicationEvent: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + type: { type: "string", example: "status_changed" }, + fromStatus: { type: "string", nullable: true }, + toStatus: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + Notification: { + type: "object", + required: ["id", "channel", "message", "createdAt"], + properties: { + id: { type: "string", format: "uuid" }, + channel: { type: "string", enum: ["notification", "message"] }, + type: { type: "string" }, + title: { type: "string" }, + message: { type: "string" }, + readAt: { type: "string", format: "date-time", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + entityType: { type: "string", nullable: true }, + entityId: { type: "string", nullable: true }, + }, + }, + NotificationListResponse: { + type: "object", + required: ["notifications", "unreadCount"], + properties: { + notifications: { type: "array", items: ref("Notification") }, + unreadCount: { type: "integer", minimum: 0, example: 1 }, + }, + }, + MutationResult: { + type: "object", + properties: { + ok: { type: "boolean", example: true }, + updated: { type: "integer", example: 1 }, + deleted: { type: "integer", example: 1 }, + }, + }, + AdminUser: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + email: { type: "string", format: "email" }, + role: { type: "string" }, + isBlocked: { type: "boolean" }, + }, + }, }, responses: { - BadRequest: json("Dados inválidos.", ref("Error"), { code: "VALIDATION_ERROR", message: "Dados de entrada inválidos." }), - Unauthorized: json("Autenticação necessária.", ref("Error"), { code: "UNAUTHORIZED", message: "Autenticação necessária." }), - Forbidden: json("Permissão insuficiente.", ref("Error"), { code: "FORBIDDEN", message: "Permissão insuficiente." }), - NotFound: json("Recurso não encontrado.", ref("Error"), { code: "NOT_FOUND", message: "Recurso não encontrado." }), - InternalError: json("Erro interno.", ref("Error"), { code: "INTERNAL_ERROR", message: "Erro inesperado." }), + BadRequest: json("Dados inválidos.", ref("Error"), { + code: "VALIDATION_ERROR", + message: "Dados de entrada inválidos.", + }), + Unauthorized: json("Autenticação necessária.", ref("Error"), { + code: "UNAUTHORIZED", + message: "Autenticação necessária.", + }), + Forbidden: json("Permissão insuficiente.", ref("Error"), { + code: "FORBIDDEN", + message: "Permissão insuficiente.", + }), + NotFound: json("Recurso não encontrado.", ref("Error"), { + code: "NOT_FOUND", + message: "Recurso não encontrado.", + }), + InternalError: json("Erro interno.", ref("Error"), { + code: "INTERNAL_ERROR", + message: "Erro inesperado.", + }), }, }, paths: { - "/health": { get: { tags: ["System"], summary: "Verifica a disponibilidade", responses: { 200: json("API disponível.", { type: "object", properties: { ok: { type: "boolean" } } }, { ok: true }) } } }, - "/auth/register": { post: { tags: ["Auth"], summary: "Cria conta e sessão", requestBody: body(ref("RegisterRequest"), { email: "ana@exemplo.com", password: "senha-segura", name: "Ana Souza" }), responses: { 201: json("Conta criada.", ref("AuthResponse")), 400: response("BadRequest"), 500: response("InternalError") } } }, - "/auth/login": { post: { tags: ["Auth"], summary: "Inicia sessão", requestBody: body(ref("LoginRequest"), { email: "ana@exemplo.com", password: "senha-segura" }), responses: { 200: json("Sessão iniciada.", ref("AuthResponse")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, - "/auth/logout": { post: { tags: ["Auth"], summary: "Encerra sessão", responses: { 200: json("Sessão encerrada.", ref("MutationResult"), { ok: true }) } } }, - "/auth/me": { get: { tags: ["Auth"], summary: "Consulta sessão atual", security: auth, responses: { 200: json("Sessão atual.", { type: "object", properties: { user: ref("UserProfile") } }), 401: response("Unauthorized") } } }, - "/auth/{provider}/url": { get: { tags: ["Auth"], summary: "Obtém URL OAuth", parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 200: json("URL de autorização.", { type: "object", properties: { url: { type: "string", format: "uri" } } }, { url: "https://accounts.example/authorize" }), 400: response("BadRequest") } } }, - "/auth/{provider}/callback": { get: { tags: ["Auth"], summary: "Processa callback OAuth", parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 302: { description: "Redireciona após autenticação." }, 400: response("BadRequest") } } }, - "/auth/connections": { get: { tags: ["Auth"], summary: "Lista conexões OAuth", security: auth, responses: { 200: json("Conexões ativas.", { type: "array", items: { type: "object", properties: { provider: { type: "string" } } } }), 401: response("Unauthorized") } } }, - "/auth/connections/{provider}": { delete: { tags: ["Auth"], summary: "Desconecta provedor OAuth", security: auth, parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 204: { description: "Conexão removida." }, 401: response("Unauthorized"), 404: response("NotFound") } } }, - "/users/profile": { get: { tags: ["Users"], summary: "Consulta perfil", security: auth, responses: { 200: json("Perfil.", ref("UserProfile")), 401: response("Unauthorized"), 404: response("NotFound") } }, patch: { tags: ["Users"], summary: "Atualiza perfil", security: auth, requestBody: body(ref("ProfileUpdateRequest"), { displayName: "Ana Souza", technologies: ["React"] }), responses: { 200: json("Perfil atualizado.", ref("UserProfile")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, - "/users/preferences": { get: { tags: ["Users"], summary: "Consulta preferências", security: auth, responses: { 200: json("Preferências.", ref("Preferences")), 401: response("Unauthorized"), 404: response("NotFound") } }, post: { tags: ["Users"], summary: "Cria preferências", security: auth, requestBody: body(ref("Preferences"), { keywords: ["react"], remoteOnly: true }), responses: { 201: json("Preferências criadas.", ref("Preferences")), 400: response("BadRequest"), 401: response("Unauthorized") } }, patch: { tags: ["Users"], summary: "Atualiza preferências", security: auth, requestBody: body(ref("Preferences"), { emailNotifications: false }), responses: { 200: json("Preferências atualizadas.", ref("Preferences")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, - "/jobs/search": { get: { tags: ["Jobs"], summary: "Busca vagas", security: auth, parameters: [{ in: "query", name: "keywords", schema: { type: "string" }, example: "react,node" }], responses: { 200: json("Vagas encontradas.", ref("JobSearchResponse"), { jobs: [{ id: "job-123", jobTitle: "Desenvolvedor Backend", company: "Candidate", jobLink: "https://empresa.exemplo/vagas/123" }], total: 1 }), 401: response("Unauthorized") } } }, + "/health": { + get: { + tags: ["System"], + summary: "Verifica a disponibilidade", + responses: { + 200: json( + "API disponível.", + { type: "object", properties: { ok: { type: "boolean" } } }, + { ok: true }, + ), + }, + }, + }, + "/auth/register": { + post: { + tags: ["Auth"], + summary: "Cria conta e sessão", + requestBody: body(ref("RegisterRequest"), { + email: "ana@exemplo.com", + password: "senha-segura", + name: "Ana Souza", + }), + responses: { + 201: json("Conta criada.", ref("AuthResponse")), + 400: response("BadRequest"), + 500: response("InternalError"), + }, + }, + }, + "/auth/login": { + post: { + tags: ["Auth"], + summary: "Inicia sessão", + requestBody: body(ref("LoginRequest"), { + email: "ana@exemplo.com", + password: "senha-segura", + }), + responses: { + 200: json("Sessão iniciada.", ref("AuthResponse")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + }, + "/auth/logout": { + post: { + tags: ["Auth"], + summary: "Encerra sessão", + responses: { + 200: json("Sessão encerrada.", ref("MutationResult"), { ok: true }), + }, + }, + }, + "/auth/me": { + get: { + tags: ["Auth"], + summary: "Consulta sessão atual", + security: auth, + responses: { + 200: json("Sessão atual.", { + type: "object", + properties: { user: ref("UserProfile") }, + }), + 401: response("Unauthorized"), + }, + }, + }, + "/auth/{provider}/url": { + get: { + tags: ["Auth"], + summary: "Obtém URL OAuth", + parameters: [ + { + in: "path", + name: "provider", + required: true, + schema: { + type: "string", + enum: ["google", "github", "linkedin"], + }, + }, + ], + responses: { + 200: json( + "URL de autorização.", + { + type: "object", + properties: { url: { type: "string", format: "uri" } }, + }, + { url: "https://accounts.example/authorize" }, + ), + 400: response("BadRequest"), + }, + }, + }, + "/auth/{provider}/callback": { + get: { + tags: ["Auth"], + summary: "Processa callback OAuth", + parameters: [ + { + in: "path", + name: "provider", + required: true, + schema: { + type: "string", + enum: ["google", "github", "linkedin"], + }, + }, + ], + responses: { + 302: { description: "Redireciona após autenticação." }, + 400: response("BadRequest"), + }, + }, + }, + "/auth/connections": { + get: { + tags: ["Auth"], + summary: "Lista conexões OAuth", + security: auth, + responses: { + 200: json("Conexões ativas.", { + type: "array", + items: { + type: "object", + properties: { provider: { type: "string" } }, + }, + }), + 401: response("Unauthorized"), + }, + }, + }, + "/auth/connections/{provider}": { + delete: { + tags: ["Auth"], + summary: "Desconecta provedor OAuth", + security: auth, + parameters: [ + { + in: "path", + name: "provider", + required: true, + schema: { + type: "string", + enum: ["google", "github", "linkedin"], + }, + }, + ], + responses: { + 204: { description: "Conexão removida." }, + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + }, + "/users/profile": { + get: { + tags: ["Users"], + summary: "Consulta perfil", + security: auth, + responses: { + 200: json("Perfil.", ref("UserProfile")), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + patch: { + tags: ["Users"], + summary: "Atualiza perfil", + security: auth, + requestBody: body(ref("ProfileUpdateRequest"), { + displayName: "Ana Souza", + technologies: ["React"], + }), + responses: { + 200: json("Perfil atualizado.", ref("UserProfile")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + }, + "/users/preferences": { + get: { + tags: ["Users"], + summary: "Consulta preferências", + security: auth, + responses: { + 200: json("Preferências.", ref("Preferences")), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + post: { + tags: ["Users"], + summary: "Cria preferências", + security: auth, + requestBody: body(ref("Preferences"), { + keywords: ["react"], + remoteOnly: true, + }), + responses: { + 201: json("Preferências criadas.", ref("Preferences")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + patch: { + tags: ["Users"], + summary: "Atualiza preferências", + security: auth, + requestBody: body(ref("Preferences"), { emailNotifications: false }), + responses: { + 200: json("Preferências atualizadas.", ref("Preferences")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + }, + "/jobs/search": { + get: { + tags: ["Jobs"], + summary: "Busca vagas", + security: auth, + description: + "OR entre famílias; AND com os demais filtros. primary considera somente classification.primaryFamily; any (default) considera principal ou relacionada. Full Stack (fullstack) é independente de backend/frontend; devops e platform são independentes. IDs são case-sensitive; labels, aliases e other não são aceitos. Valores vazios são removidos, mas family presente sem IDs retorna 400. Duplicidades são removidas antes do máximo de 13 famílias únicas e IDs são ordenados. familyMode sem family é validado e não altera a busca. Total e paginação usam o mesmo predicado antes de paginar.", + parameters: [ + { + in: "query", + name: "family", + style: "form", + explode: true, + description: + "Uma família ou múltiplas por vírgula, repetição ou combinação: ?family=backend; ?family=backend,fullstack; ?family=backend&family=fullstack; ?family=backend,fullstack&family=product. Todos os formatos são equivalentes. Espaços externos e vírgula final são normalizados. ?family= e ?family=,,, retornam INVALID_JOB_FAMILY.", + schema: { + type: "array", + maxItems: 13, + items: { + type: "string", + enum: professionalFamilies.map((f) => f.id), + }, + }, + examples: { + single: { value: ["backend"] }, + multiple: { value: ["backend", "fullstack"] }, + product: { value: ["product", "product_design"] }, + }, + }, + { + in: "query", + name: "familyMode", + description: + "primary: somente principal; any: principal ou relacionada. Sem family, apenas valida o valor. Repetição não é aceita.", + schema: { + type: "string", + enum: ["primary", "any"], + default: "any", + }, + example: "primary", + }, + ...["keywords", "technology", "company", "model", "type"].map( + (name) => ({ + in: "query", + name, + schema: { type: "string" }, + description: + "Aceita vírgulas e parâmetros repetidos; model prevalece sobre type.", + }), + ), + ...[ + "level", + "seniority", + "location", + "continent", + "country", + "state", + "city", + "contract", + "contractType", + "jobTypes", + ].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + { + in: "query", + name: "matchSort", + schema: { type: "string", enum: ["asc", "desc"] }, + }, + { + in: "query", + name: "sort", + description: "Alias de matchSort.", + schema: { type: "string", enum: ["asc", "desc"] }, + }, + { + in: "query", + name: "page", + schema: { type: "integer", minimum: 1, default: 1 }, + }, + { + in: "query", + name: "limit", + schema: { + type: "integer", + minimum: 1, + maximum: 100, + default: 100, + }, + }, + ], + responses: { + 200: json("Vagas encontradas.", ref("JobSearchResponse"), { + jobs: [ + { + id: "job-123", + jobTitle: "Desenvolvedor Backend", + company: "Candidate", + jobLink: "https://empresa.exemplo/vagas/123", + }, + ], + total: 1, + page: 1, + limit: 100, + totalPages: 1, + hasNext: false, + hasPrev: false, + source: "valkey_global_index:verified_batches", + }), + 400: { + description: + "Família ou modo inválido; nenhuma consulta de vagas executada.", + content: { + "application/json": { + schema: ref("Error"), + examples: { + family: { + value: { + code: "INVALID_JOB_FAMILY", + message: "Informe de 1 a 13 famílias públicas válidas.", + }, + }, + mode: { + value: { + code: "INVALID_FAMILY_MODE", + message: "familyMode deve ser any ou primary.", + }, + }, + }, + }, + }, + }, + 401: response("Unauthorized"), + 500: json("Falha na persistência (envelope legado da busca).", { + type: "object", + properties: { + message: { type: "string" }, + error: { type: "string" }, + }, + }), + }, + }, + }, + "/jobs/filters/options": { + get: { + tags: ["Jobs"], + summary: "Opções canônicas de filtros", + security: auth, + description: + "Taxonomia PAV-123, sem other e sem consultar vagas ou scraper. Ordem canônica determinística; labels somente para apresentação. Cache-Control: public, max-age=3600. ETag SHA-256 da versão e conteúdo; suporta If-None-Match.", + parameters: [ + { in: "header", name: "If-None-Match", schema: { type: "string" } }, + ], + responses: { + 200: { + ...json( + "Opções de filtros.", + ref("JobFilterOptions"), + jobFilterOptions, + ), + headers: { + "Cache-Control": { + schema: { type: "string" }, + example: "public, max-age=3600", + }, + ETag: { schema: { type: "string" } }, + }, + }, + 304: { + description: "Taxonomia não modificada.", + headers: { + ETag: { schema: { type: "string" } }, + "Cache-Control": { schema: { type: "string" } }, + }, + }, + 401: response("Unauthorized"), + }, + }, + }, "/saved-jobs": { - get: { tags: ["Saved jobs"], summary: "Lista vagas salvas", security: auth, responses: { 200: json("Vagas salvas.", { type: "array", items: ref("SavedJob") }), 401: response("Unauthorized") } }, - post: { tags: ["Saved jobs"], summary: "Salva vaga", security: auth, requestBody: body(ref("SavedJobRequest"), { jobLink: "https://empresa.exemplo/vagas/123", jobTitle: "Desenvolvedor Backend", status: "saved" }), responses: { 201: json("Vaga salva.", ref("SavedJob")), 400: response("BadRequest"), 401: response("Unauthorized") } }, + get: { + tags: ["Saved jobs"], + summary: "Lista vagas salvas", + security: auth, + responses: { + 200: json("Vagas salvas.", { + type: "array", + items: ref("SavedJob"), + }), + 401: response("Unauthorized"), + }, + }, + post: { + tags: ["Saved jobs"], + summary: "Salva vaga", + security: auth, + requestBody: body(ref("SavedJobRequest"), { + jobLink: "https://empresa.exemplo/vagas/123", + jobTitle: "Desenvolvedor Backend", + status: "saved", + }), + responses: { + 201: json("Vaga salva.", ref("SavedJob")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, }, "/saved-jobs/{id}": { - get: { tags: ["Saved jobs"], summary: "Consulta vaga salva", security: auth, parameters: [id], responses: { 200: json("Vaga salva.", ref("SavedJob")), 401: response("Unauthorized"), 404: response("NotFound") } }, - patch: { tags: ["Saved jobs"], summary: "Atualiza vaga salva", security: auth, parameters: [id], requestBody: body(ref("SavedJobRequest"), { status: "applied", notes: "Candidatura enviada." }), responses: { 200: json("Vaga atualizada.", ref("SavedJob")), 400: response("BadRequest"), 401: response("Unauthorized"), 404: response("NotFound") } }, - delete: { tags: ["Saved jobs"], summary: "Remove vaga salva", security: auth, parameters: [id], responses: { 204: { description: "Vaga removida." }, 401: response("Unauthorized"), 404: response("NotFound") } }, + get: { + tags: ["Saved jobs"], + summary: "Consulta vaga salva", + security: auth, + parameters: [id], + responses: { + 200: json("Vaga salva.", ref("SavedJob")), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + patch: { + tags: ["Saved jobs"], + summary: "Atualiza vaga salva", + security: auth, + parameters: [id], + requestBody: body(ref("SavedJobRequest"), { + status: "applied", + notes: "Candidatura enviada.", + }), + responses: { + 200: json("Vaga atualizada.", ref("SavedJob")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + delete: { + tags: ["Saved jobs"], + summary: "Remove vaga salva", + security: auth, + parameters: [id], + responses: { + 204: { description: "Vaga removida." }, + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, + }, + "/saved-jobs/{id}/events": { + get: { + tags: ["Saved jobs"], + summary: "Lista histórico da candidatura", + security: auth, + parameters: [id], + responses: { + 200: json("Eventos.", { + type: "array", + items: ref("ApplicationEvent"), + }), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, }, - "/saved-jobs/{id}/events": { get: { tags: ["Saved jobs"], summary: "Lista histórico da candidatura", security: auth, parameters: [id], responses: { 200: json("Eventos.", { type: "array", items: ref("ApplicationEvent") }), 401: response("Unauthorized"), 404: response("NotFound") } } }, "/notifications": { - get: { tags: ["Notifications"], summary: "Lista notificações ou mensagens", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }, { in: "query", name: "unreadOnly", schema: { type: "boolean" } }, { in: "query", name: "limit", schema: { type: "integer", minimum: 1, maximum: 100, default: 30 } }], responses: { 200: json("Feed.", ref("NotificationListResponse"), { notifications: [], unreadCount: 0 }), 400: response("BadRequest"), 401: response("Unauthorized") } }, - delete: { tags: ["Notifications"], summary: "Limpa notificações", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }], responses: { 200: json("Notificações removidas.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized") } }, + get: { + tags: ["Notifications"], + summary: "Lista notificações ou mensagens", + security: auth, + parameters: [ + { + in: "query", + name: "channel", + schema: { type: "string", enum: ["notification", "message"] }, + }, + { in: "query", name: "unreadOnly", schema: { type: "boolean" } }, + { + in: "query", + name: "limit", + schema: { + type: "integer", + minimum: 1, + maximum: 100, + default: 30, + }, + }, + ], + responses: { + 200: json("Feed.", ref("NotificationListResponse"), { + notifications: [], + unreadCount: 0, + }), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + delete: { + tags: ["Notifications"], + summary: "Limpa notificações", + security: auth, + parameters: [ + { + in: "query", + name: "channel", + schema: { type: "string", enum: ["notification", "message"] }, + }, + ], + responses: { + 200: json("Notificações removidas.", ref("MutationResult")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + }, + "/notifications/read-all": { + patch: { + tags: ["Notifications"], + summary: "Marca todas como lidas", + security: auth, + parameters: [ + { + in: "query", + name: "channel", + schema: { type: "string", enum: ["notification", "message"] }, + }, + ], + responses: { + 200: json("Notificações atualizadas.", ref("MutationResult")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + }, + }, + }, + "/notifications/{id}/read": { + patch: { + tags: ["Notifications"], + summary: "Marca uma notificação como lida", + security: auth, + parameters: [id], + responses: { + 200: json("Notificação atualizada.", ref("Notification")), + 401: response("Unauthorized"), + 404: response("NotFound"), + }, + }, }, - "/notifications/read-all": { patch: { tags: ["Notifications"], summary: "Marca todas como lidas", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }], responses: { 200: json("Notificações atualizadas.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, - "/notifications/{id}/read": { patch: { tags: ["Notifications"], summary: "Marca uma notificação como lida", security: auth, parameters: [id], responses: { 200: json("Notificação atualizada.", ref("Notification")), 401: response("Unauthorized"), 404: response("NotFound") } } }, "/keywords": { get: { - tags: ["Keywords"], summary: "Lista keywords", security: auth, + tags: ["Keywords"], + summary: "Lista keywords", + security: auth, responses: { - 200: json("Keywords.", { type: "object", properties: { - ok: { type: "boolean" }, - keywords: { type: "array", items: { type: "object", properties: { keyword: { type: "string" }, source: { type: "string" } } } }, - } }), + 200: json("Keywords.", { + type: "object", + properties: { + ok: { type: "boolean" }, + keywords: { + type: "array", + items: { + type: "object", + properties: { + keyword: { type: "string" }, + source: { type: "string" }, + }, + }, + }, + }, + }), 401: response("Unauthorized"), }, }, post: { - tags: ["Keywords"], summary: "Enfileira keyword", security: auth, - requestBody: body({ type: "object", required: ["keyword"], properties: { keyword: { type: "string" } } }, { keyword: "golang" }), - responses: { 202: json("Keyword enfileirada.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden") }, - }, - }, - "/admin/dashboard": { get: { tags: ["Admin"], summary: "Consulta dashboard administrativo", security: auth, responses: { 200: json("Resumo administrativo.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/users": { get: { tags: ["Admin"], summary: "Lista usuários administrativos", security: auth, responses: { 200: json("Usuários.", { type: "array", items: ref("AdminUser") }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/users/{id}": { get: { tags: ["Admin"], summary: "Consulta usuário", security: auth, parameters: [id], responses: { 200: json("Usuário.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } }, delete: { tags: ["Admin"], summary: "Exclui usuário", security: auth, parameters: [id], responses: { 204: { description: "Usuário removido." }, 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/users/{id}/block": { patch: { tags: ["Admin"], summary: "Bloqueia usuário", security: auth, parameters: [id], responses: { 200: json("Usuário bloqueado.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/users/{id}/unblock": { patch: { tags: ["Admin"], summary: "Desbloqueia usuário", security: auth, parameters: [id], responses: { 200: json("Usuário desbloqueado.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/users/{id}/reset": { post: { tags: ["Admin"], summary: "Solicita redefinição de senha", security: auth, parameters: [id], responses: { 200: json("Redefinição solicitada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/users/{id}/role": { patch: { tags: ["Admin"], summary: "Altera o papel de um usuário", security: auth, parameters: [id], requestBody: body({ type: "object", required: ["role"], properties: { role: { type: "string", enum: ["user", "support", "admin", "super_admin"] } } }, { role: "support" }), responses: { 200: json("Papel atualizado.", ref("AdminUser")), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/scrapers": { get: { tags: ["Admin"], summary: "Lista scrapers", security: auth, responses: { 200: json("Scrapers.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/scrapers/run": { post: { tags: ["Admin"], summary: "Dispara todos os scrapers", security: auth, responses: { 202: json("Execução iniciada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/scrapers/{id}/run": { post: { tags: ["Admin"], summary: "Dispara um scraper", security: auth, parameters: [id], responses: { 202: json("Execução iniciada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, - "/admin/scrapers/status": { get: { tags: ["Admin"], summary: "Consulta status dos scrapers", security: auth, responses: { 200: json("Status dos scrapers.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/scrapers/jobs": { get: { tags: ["Admin"], summary: "Lista vagas coletadas", security: auth, responses: { 200: json("Vagas coletadas.", { type: "array", items: ref("Job") }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/scrapers/jobs/count": { get: { tags: ["Admin"], summary: "Conta vagas coletadas", security: auth, responses: { 200: json("Contagem de vagas.", { type: "object", properties: { count: { type: "integer", example: 42 } } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/observability/health": { get: { tags: ["Admin"], summary: "Consulta saúde operacional", security: auth, responses: { 200: json("Saúde operacional.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/observability/metrics": { get: { tags: ["Admin"], summary: "Consulta métricas operacionais", security: auth, responses: { 200: json("Métricas operacionais.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/observability/dashboards": { get: { tags: ["Admin"], summary: "Lista dashboards operacionais", security: auth, responses: { 200: json("Dashboards operacionais.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, - "/admin/audit": { get: { tags: ["Admin"], summary: "Consulta logs de auditoria", security: auth, responses: { 200: json("Logs.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + tags: ["Keywords"], + summary: "Enfileira keyword", + security: auth, + requestBody: body( + { + type: "object", + required: ["keyword"], + properties: { keyword: { type: "string" } }, + }, + { keyword: "golang" }, + ), + responses: { + 202: json("Keyword enfileirada.", ref("MutationResult")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/dashboard": { + get: { + tags: ["Admin"], + summary: "Consulta dashboard administrativo", + security: auth, + responses: { + 200: json("Resumo administrativo.", { + type: "object", + additionalProperties: true, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/users": { + get: { + tags: ["Admin"], + summary: "Lista usuários administrativos", + security: auth, + responses: { + 200: json("Usuários.", { type: "array", items: ref("AdminUser") }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/users/{id}": { + get: { + tags: ["Admin"], + summary: "Consulta usuário", + security: auth, + parameters: [id], + responses: { + 200: json("Usuário.", ref("AdminUser")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + delete: { + tags: ["Admin"], + summary: "Exclui usuário", + security: auth, + parameters: [id], + responses: { + 204: { description: "Usuário removido." }, + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/users/{id}/block": { + patch: { + tags: ["Admin"], + summary: "Bloqueia usuário", + security: auth, + parameters: [id], + responses: { + 200: json("Usuário bloqueado.", ref("AdminUser")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/users/{id}/unblock": { + patch: { + tags: ["Admin"], + summary: "Desbloqueia usuário", + security: auth, + parameters: [id], + responses: { + 200: json("Usuário desbloqueado.", ref("AdminUser")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/users/{id}/reset": { + post: { + tags: ["Admin"], + summary: "Solicita redefinição de senha", + security: auth, + parameters: [id], + responses: { + 200: json("Redefinição solicitada.", ref("MutationResult")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/users/{id}/role": { + patch: { + tags: ["Admin"], + summary: "Altera o papel de um usuário", + security: auth, + parameters: [id], + requestBody: body( + { + type: "object", + required: ["role"], + properties: { + role: { + type: "string", + enum: ["user", "support", "admin", "super_admin"], + }, + }, + }, + { role: "support" }, + ), + responses: { + 200: json("Papel atualizado.", ref("AdminUser")), + 400: response("BadRequest"), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/scrapers": { + get: { + tags: ["Admin"], + summary: "Lista scrapers", + security: auth, + responses: { + 200: json("Scrapers.", { + type: "array", + items: { type: "object", additionalProperties: true }, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/scrapers/run": { + post: { + tags: ["Admin"], + summary: "Dispara todos os scrapers", + security: auth, + responses: { + 202: json("Execução iniciada.", ref("MutationResult")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/scrapers/{id}/run": { + post: { + tags: ["Admin"], + summary: "Dispara um scraper", + security: auth, + parameters: [id], + responses: { + 202: json("Execução iniciada.", ref("MutationResult")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 404: response("NotFound"), + }, + }, + }, + "/admin/scrapers/status": { + get: { + tags: ["Admin"], + summary: "Consulta status dos scrapers", + security: auth, + responses: { + 200: json("Status dos scrapers.", { + type: "object", + additionalProperties: true, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/scrapers/jobs": { + get: { + tags: ["Admin"], + summary: "Lista vagas coletadas", + security: auth, + responses: { + 200: json("Vagas coletadas.", { type: "array", items: ref("Job") }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/scrapers/jobs/count": { + get: { + tags: ["Admin"], + summary: "Conta vagas coletadas", + security: auth, + responses: { + 200: json("Contagem de vagas.", { + type: "object", + properties: { count: { type: "integer", example: 42 } }, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/observability/health": { + get: { + tags: ["Admin"], + summary: "Consulta saúde operacional", + security: auth, + responses: { + 200: json("Saúde operacional.", { + type: "object", + additionalProperties: true, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/observability/metrics": { + get: { + tags: ["Admin"], + summary: "Consulta métricas operacionais", + security: auth, + responses: { + 200: json("Métricas operacionais.", { + type: "object", + additionalProperties: true, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/observability/dashboards": { + get: { + tags: ["Admin"], + summary: "Lista dashboards operacionais", + security: auth, + responses: { + 200: json("Dashboards operacionais.", { + type: "array", + items: { type: "object", additionalProperties: true }, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/audit": { + get: { + tags: ["Admin"], + summary: "Consulta logs de auditoria", + security: auth, + responses: { + 200: json("Logs.", { type: "object", additionalProperties: true }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, "/admin/permissions/rules": { - get: { tags: ["Admin"], summary: "Lista regras de permissão", security: auth, responses: { 200: json("Regras de permissão.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } }, - patch: { tags: ["Admin"], summary: "Atualiza regras de permissão", security: auth, requestBody: body({ type: "object", additionalProperties: true }, {}), responses: { 200: json("Regras atualizadas.", { type: "array", items: { type: "object", additionalProperties: true } }), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden") } }, + get: { + tags: ["Admin"], + summary: "Lista regras de permissão", + security: auth, + responses: { + 200: json("Regras de permissão.", { + type: "array", + items: { type: "object", additionalProperties: true }, + }), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + patch: { + tags: ["Admin"], + summary: "Atualiza regras de permissão", + security: auth, + requestBody: body({ type: "object", additionalProperties: true }, {}), + responses: { + 200: json("Regras atualizadas.", { + type: "array", + items: { type: "object", additionalProperties: true }, + }), + 400: response("BadRequest"), + 401: response("Unauthorized"), + 403: response("Forbidden"), + }, + }, + }, + "/admin/jobs/cache": { + delete: { + tags: ["Admin"], + summary: "Limpa o cache de vagas", + security: auth, + responses: { + 200: json("Cache limpo.", ref("MutationResult")), + 401: response("Unauthorized"), + 403: response("Forbidden"), + 500: response("InternalError"), + }, + }, }, - "/admin/jobs/cache": { delete: { tags: ["Admin"], summary: "Limpa o cache de vagas", security: auth, responses: { 200: json("Cache limpo.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 500: response("InternalError") } } }, }, }, apis: [path.resolve("src/**/*.ts")], diff --git a/backend/tests/integration/routes/searchJobs.routes.test.ts b/backend/tests/integration/routes/searchJobs.routes.test.ts index 91bd7162..62b4763b 100644 --- a/backend/tests/integration/routes/searchJobs.routes.test.ts +++ b/backend/tests/integration/routes/searchJobs.routes.test.ts @@ -39,7 +39,7 @@ const baseJobs = [ location: "Joinville, Santa Catarina - Brasil", description: "Vaga presencial CLT", classification: { - primaryFamily: "Backend", + primaryFamily: "backend", technologies: ["Go", "PostgreSQL"], seniority: "Sênior", }, @@ -51,7 +51,7 @@ const baseJobs = [ location: "Curitiba, Paraná - Brasil", description: "Vaga presencial CLT", classification: { - primaryFamily: "Backend", + primaryFamily: "backend", technologies: ["Go", "PostgreSQL"], seniority: "Sênior", }, @@ -64,7 +64,7 @@ const baseJobs = [ modality: "Hybrid", description: "React, full time", classification: { - primaryFamily: "Frontend", + primaryFamily: "frontend", technologies: ["React", "TypeScript"], seniority: "Pleno", }, @@ -77,7 +77,7 @@ const baseJobs = [ modality: "Remote", description: "React, TypeScript, PJ", classification: { - primaryFamily: "Frontend", + primaryFamily: "frontend", technologies: ["React", "TypeScript"], seniority: "Pleno", }, @@ -95,20 +95,21 @@ function setStructuredJobs( jobs: Array<{ id: string; [key: string]: unknown }> = baseJobs, ) { cacheMocks.cacheSearchJobIds.mockResolvedValue(jobs.map((job) => job.id)); - cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(jobs.map((job) => job.id)); + cacheMocks.cacheGetJobsByIds.mockImplementation(async (requested: string[]) => requested.flatMap(id => jobs.filter(job => job.id === id))); } function setLegacyJobs( jobs: Array<{ id: string; [key: string]: unknown }> = baseJobs, ) { cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(jobs.map((job) => job.id)); - cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); + cacheMocks.cacheGetJobsByIds.mockImplementation(async (requested: string[]) => requested.flatMap(id => jobs.filter(job => job.id === id))); } function setFallbackJobs(jobs = baseJobs) { cacheMocks.cacheSearchJobIds.mockResolvedValue([]); cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(jobs.map((job) => job.id)); - cacheMocks.cacheGetJobsByIds.mockResolvedValue(jobs); + cacheMocks.cacheGetJobsByIds.mockImplementation(async (requested: string[]) => requested.flatMap(id => jobs.filter(job => job.id === id))); } beforeEach(() => { @@ -161,11 +162,11 @@ describe("Integration - GET /jobs/search", () => { }); it("retorna erro HTTP quando a busca de índices falha", async () => { - cacheMocks.cacheSearchJobIds.mockRejectedValueOnce(new Error("cache down")); + cacheMocks.cacheAbsoluteSMembers.mockRejectedValueOnce(new Error("cache down")); const response = await request(app) .get(searchUrl) - .query({ family: "Backend" }) + .query({ family: "backend" }) .expect(500); expect(response.body).toMatchObject({ @@ -174,17 +175,17 @@ describe("Integration - GET /jobs/search", () => { }); }); - it("combina filtros estruturados e pós-filtra as candidatas do cache", async () => { + it("combina filtros antes de contar e selecionar a página", async () => { setStructuredJobs(); const response = await request(app) .get(searchUrl) - .query({ technology: "Go", family: "Backend", country: "Brasil", state: "SC", city: "Joinville", contract: "clt" }) + .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"); + expect(response.body.source).toContain("verified_batches"); }); it.each([ @@ -200,17 +201,17 @@ describe("Integration - GET /jobs/search", () => { expect(ids(response.body.jobs)).toEqual([expected]); }); - it("usa cache estruturado preenchido e valida a resposta final", async () => { + it("verifica documentos mesmo com índices estruturados preenchidos", async () => { setStructuredJobs(); const response = await request(app) .get(searchUrl) - .query({ family: "Backend" }) + .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"); + expect(cacheMocks.cacheSearchJobIds).not.toHaveBeenCalled(); + expect(response.body.source).toContain("verified_batches"); }); it("respeita o limite máximo de 100 resultados por página", async () => { @@ -218,13 +219,13 @@ describe("Integration - GET /jobs/search", () => { id: `job-${index}`, title: `Engineer ${index}`, location: "Remote", - classification: { primaryFamily: "Backend" }, + classification: { primaryFamily: "backend" }, })); setStructuredJobs(jobs); const response = await request(app) .get(searchUrl) - .query({ family: "Backend", page: "1", limit: "101" }) + .query({ family: "backend", page: "1", limit: "101" }) .expect(200); expect(response.body.jobs).toHaveLength(100); @@ -245,16 +246,16 @@ describe("Integration - GET /jobs/search", () => { query: { continent: "America do Sul", state: "SC", city: "Joinville" }, expected: ["joinville-go"], }, - ])("preserva filtros de localização no fallback: $query", async ({ query, expected }) => { + ])("preserva filtros de localização na consulta em lotes: $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"); + expect(response.body.source).toContain("verified_batches"); }); - it("preserva paginação depois de filtrar no fallback", async () => { + it("preserva paginação sobre documentos filtrados", async () => { setFallbackJobs(); const response = await request(app) @@ -331,3 +332,75 @@ describe("Integration - GET /jobs/search", () => { expect(profileMocks.getUserById).toHaveBeenCalledWith("user-search"); }); }); + +describe("PAV-124 HTTP contract", () => { + const data = [ + { id: "a", title: "Backend Senior", modality: "Remote", location: "São Paulo, Brasil", description: "CLT", classification: { primaryFamily: "backend", relatedFamilies: [], seniority: "senior" } }, + { id: "b", title: "Full Stack Senior", modality: "Remote", location: "São Paulo, Brasil", description: "CLT", classification: { primaryFamily: "fullstack", relatedFamilies: [], seniority: "senior" } }, + { id: "c", title: "Tech Lead", classification: { primaryFamily: "leadership", relatedFamilies: ["backend"] } }, + { id: "d", title: "Frontend", classification: { primaryFamily: "frontend", relatedFamilies: [] } }, + ]; + it.each([ + "family=backend,fullstack", "family=fullstack,backend", "family=backend&family=fullstack", "family=fullstack,backend&family=backend", "family=%20backend%20,fullstack,", + ])("equivalent query %s including pagination", async query => { + setStructuredJobs(data); + const response = await request(app).get(`${searchUrl}?${query}&limit=1&page=2`).expect(200); + expect(response.body).toMatchObject({ jobs: [{ id: "b" }], total: 3, page: 2, totalPages: 3 }); + }); + it.each([ + ["family=backend", ["a", "c"]], ["family=backend&familyMode=primary", ["a"]], + ["family=backend,fullstack&familyMode=primary", ["a", "b"]], + ["family=backend,fullstack&seniority=senior&model=remoto&country=Brasil&contract=clt", ["a", "b"]], + ["family=fullstack&familyMode=primary", ["b"]], + ])("applies %s before pagination and total", async (query, expected) => { + setStructuredJobs(data); + const response = await request(app).get(`${searchUrl}?${query}`).expect(200); + expect(ids(response.body.jobs)).toEqual(expected); + expect(response.body.total).toBe(expected.length); + }); + it.each([ + ["family=backend,finance", "INVALID_JOB_FAMILY"], ["family=Backend", "INVALID_JOB_FAMILY"], + ["family=other", "INVALID_JOB_FAMILY"], ["family=", "INVALID_JOB_FAMILY"], + ["family=,,,", "INVALID_JOB_FAMILY"], ["familyMode=related", "INVALID_FAMILY_MODE"], + ["family=backend&familyMode=related", "INVALID_FAMILY_MODE"], + ["familyMode=any&familyMode=primary", "INVALID_FAMILY_MODE"], + ])("returns stable 400 for %s without consulting persistence or profile", async (query, code) => { + const response = await request(app).get(`${searchUrl}?${query}`).expect(400); + expect(response.body).toEqual({ code, message: expect.any(String) }); + expect(cacheMocks.cacheAbsoluteSMembers).not.toHaveBeenCalled(); + expect(cacheMocks.cacheSearchKeywords).not.toHaveBeenCalled(); + expect(cacheMocks.cacheSearchJobIds).not.toHaveBeenCalled(); + expect(cacheMocks.cacheGetJobsByIds).not.toHaveBeenCalled(); + expect(profileMocks.getUserById).not.toHaveBeenCalled(); + }); + it.each(["any", "primary"])("mode %s without family does not filter", async familyMode => { + cacheMocks.cacheAbsoluteSMembers.mockResolvedValue(baseJobs.map(job => job.id)); + const response = await request(app).get(searchUrl).query({ familyMode }).expect(200); + expect(ids(response.body.jobs)).toEqual(ids(baseJobs)); + }); + it("retains the authenticated boundary for search and options", async () => { + const { getIronSession } = await import("iron-session"); + vi.mocked(getIronSession).mockResolvedValueOnce({} as never); + await request(app).get(searchUrl).expect(401); + vi.mocked(getIronSession).mockResolvedValueOnce({} as never); + await request(app).get("/api/v1/jobs/filters/options").expect(401); + expect(cacheMocks.cacheAbsoluteSMembers).not.toHaveBeenCalled(); + }); + it("options returns canonical taxonomy, modes, labels, cache and 304 without queries", async () => { + const { professionalFamilies, taxonomyVersion } = await import("../../../src/modules/jobs/types/professionalTaxonomy"); + const response = await request(app).get("/api/v1/jobs/filters/options").expect(200); + expect(response.body).toEqual({ taxonomyVersion, families: professionalFamilies, familyModes: [ + { id: "any", label: "Principal ou relacionada", default: true }, + { id: "primary", label: "Somente família principal", default: false }, + ] }); + expect(response.body.families).toHaveLength(13); + expect(response.body.families.some((f: { id: string }) => f.id === "other")).toBe(false); + expect(response.headers["cache-control"]).toBe("public, max-age=3600"); + expect(response.headers.etag).toMatch(/^"[a-f0-9]{64}"$/); + await request(app).get("/api/v1/jobs/filters/options").set("If-None-Match", response.headers.etag).expect(304); + expect(cacheMocks.cacheAbsoluteSMembers).not.toHaveBeenCalled(); + expect(cacheMocks.cacheGetJobsByIds).not.toHaveBeenCalled(); + expect(cacheMocks.cacheSearchJobIds).not.toHaveBeenCalled(); + expect(profileMocks.getUserById).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index 9b196797..3abe8c11 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -377,8 +377,8 @@ describe("jobsApiApp", () => { expect(res.body.total).toBe(1); }); - it("GET /jobs/search usa índices estruturados quando há filtros", async () => { - mocks.cacheSearchJobIds.mockResolvedValue(["id-structured"]); + it("GET /jobs/search consulta documentos em lotes quando há filtros", async () => { + mocks.cacheSearchKeywords.mockResolvedValue(["id-structured"]); mocks.cacheGetJobsByIds.mockResolvedValue([ { id: "id-structured", @@ -399,33 +399,19 @@ describe("jobsApiApp", () => { }) .expect(200); - expect(mocks.cacheSearchJobIds).toHaveBeenCalledWith({ - keywords: ["React"], - family: [], - technology: [], - seniority: "", - level: "Júnior", - location: "Brasil", - continent: "", - country: "", - state: "", - city: "", - type: ["Remoto"], - model: ["Remoto"], - contract: "", - }); + expect(mocks.cacheSearchJobIds).not.toHaveBeenCalled(); expect(mocks.jobSearchesInc).toHaveBeenCalledWith({ has_keywords: "true" }); - expect(mocks.cacheSearchKeywords).not.toHaveBeenCalled(); + expect(mocks.cacheSearchKeywords).toHaveBeenCalledWith(["React"]); expect(mocks.cacheGetJobsByIds).toHaveBeenCalledWith(["id-structured"]); expect(res.body.jobs).toEqual([ expect.objectContaining({ id: "id-structured" }), ]); - expect(res.body.source).toContain("structured_indexes"); + expect(res.body.source).toContain("verified_batches"); expect(res.body.source).toContain("verified"); }); - it("GET /jobs/search valida resultados dos índices estruturados antes de responder", async () => { - mocks.cacheSearchJobIds.mockResolvedValue([ + it("GET /jobs/search valida documentos antes de contar e paginar", async () => { + mocks.cacheAbsoluteSMembers.mockResolvedValue([ "id-good", "id-pleno", "id-presencial", @@ -476,11 +462,11 @@ describe("jobsApiApp", () => { ]); expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-good" })]); expect(res.body.total).toBe(1); - expect(res.body.source).toBe("valkey_global_index:structured_indexes:verified"); + expect(res.body.source).toBe("valkey_global_index:verified_batches"); }); it("GET /jobs/search filtra vagas de estágio e trainee", async () => { - mocks.cacheSearchJobIds.mockResolvedValue(["id-intern", "id-junior"]); + mocks.cacheAbsoluteSMembers.mockResolvedValue(["id-intern", "id-junior"]); mocks.cacheGetJobsByIds.mockResolvedValue([ { id: "id-intern", @@ -506,9 +492,7 @@ describe("jobsApiApp", () => { }) .expect(200); - expect(mocks.cacheSearchJobIds).toHaveBeenCalledWith( - expect.objectContaining({ level: "Estágio/Trainee" }), - ); + expect(mocks.cacheSearchJobIds).not.toHaveBeenCalled(); expect(res.body.jobs).toEqual([ expect.objectContaining({ id: "id-intern" }), ]); @@ -596,7 +580,7 @@ describe("jobsApiApp", () => { }, })); mocks.cacheAbsoluteSMembers.mockResolvedValue(["low", "high", "middle"]); - mocks.cacheGetJobsByIds.mockResolvedValue([ + const matchJobs = [ { id: "low", title: "Customer Success", @@ -615,7 +599,8 @@ describe("jobsApiApp", () => { company: "Initech", location: "Brasil", }, - ]); + ]; + mocks.cacheGetJobsByIds.mockImplementation(async (ids: string[]) => ids.flatMap(id => matchJobs.filter(job => job.id === id))); mocks.getUserById.mockResolvedValue({ id: "test-user-id", technologies: ["React", "TypeScript", "Node"], @@ -638,7 +623,7 @@ describe("jobsApiApp", () => { "middle", ]); expect(res.body.total).toBe(3); - expect(res.body.source).toBe("valkey_global_index:match_sorted_desc"); + expect(res.body.source).toBe("valkey_global_index:verified_batches:match_sorted_desc"); }); it("GET /jobs/search retorna paginação correta", async () => { diff --git a/backend/tests/unit/modules/jobs/familyQuery.parser.test.ts b/backend/tests/unit/modules/jobs/familyQuery.parser.test.ts new file mode 100644 index 00000000..543f5dcd --- /dev/null +++ b/backend/tests/unit/modules/jobs/familyQuery.parser.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { parseFamilyQuery } from "../../../../src/modules/jobs/parsers/familyQuery.parser"; +import { professionalFamilies } from "../../../../src/modules/jobs/types/professionalTaxonomy"; + +describe("family query contract", () => { + it.each([ + ["backend", ["backend"]], + ["fullstack,backend", ["backend", "fullstack"]], + [ + ["fullstack", "backend"], + ["backend", "fullstack"], + ], + [ + [" fullstack, backend ", "backend,,"], + ["backend", "fullstack"], + ], + ["backend,", ["backend"]], + [ + professionalFamilies + .map((f) => f.id) + .reverse() + .join(","), + professionalFamilies.map((f) => f.id).sort(), + ], + ])("normalizes %j deterministically", (family, expected) => { + expect(parseFamilyQuery({ family })).toEqual({ + families: expected, + familyMode: "any", + }); + }); + it.each(["primary", "any"])( + "accepts %s with or without families", + (familyMode) => { + expect( + parseFamilyQuery({ family: "backend", familyMode }).familyMode, + ).toBe(familyMode); + expect(parseFamilyQuery({ familyMode })).toEqual({ + families: [], + familyMode, + }); + }, + ); + it("defaults without family", () => + expect(parseFamilyQuery({})).toEqual({ families: [], familyMode: "any" })); + it.each([ + "", + ",,,", + " ", + "backend,finance", + "other", + "Backend", + "Full Stack", + "Dados e IA", + "Design de Produto", + "dados", + { id: "backend" }, + ["backend", {}], + ])("rejects invalid family %j", (family) => { + expect(() => parseFamilyQuery({ family })).toThrow( + expect.objectContaining({ code: "INVALID_JOB_FAMILY", statusCode: 400 }), + ); + }); + it.each(["related", "", "ANY", ["primary", "any"], {}])( + "rejects invalid mode %j even without family", + (familyMode) => { + expect(() => parseFamilyQuery({ familyMode })).toThrow( + expect.objectContaining({ + code: "INVALID_FAMILY_MODE", + statusCode: 400, + }), + ); + }, + ); + it.each(professionalFamilies.map((f) => f.id))( + "accepts canonical %s", + (family) => { + expect(parseFamilyQuery({ family }).families).toEqual([family]); + }, + ); + it("deduplicates before applying maximum", () => { + expect( + parseFamilyQuery({ family: Array(30).fill("backend") }).families, + ).toEqual(["backend"]); + }); +}); diff --git a/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts b/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts index 6ce6d2b0..0df36371 100644 --- a/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts +++ b/backend/tests/unit/modules/jobs/jobSearch.filter.test.ts @@ -105,7 +105,7 @@ describe("filterJobs", () => { it("filtra por tecnologia e família da classificação", () => { expect(filterJobs(JOBS, filters({ technology: "Go" }))).toHaveLength(1); - expect(filterJobs(JOBS, filters({ family: "Frontend" }))).toHaveLength(1); + expect(filterJobs(JOBS, filters({ family: "frontend" }))).toHaveLength(1); }); it("filtra por senioridade e modalidade", () => { diff --git a/backend/tests/unit/modules/jobs/jobSearch.repository.test.ts b/backend/tests/unit/modules/jobs/jobSearch.repository.test.ts new file mode 100644 index 00000000..7805727a --- /dev/null +++ b/backend/tests/unit/modules/jobs/jobSearch.repository.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../../../../src/lib/cache", () => ({ + cacheAbsoluteSMembers: vi.fn(), + cacheGetJobsByIds: vi.fn(), + cacheSearchKeywords: vi.fn(), +})); +import { + cacheAbsoluteSMembers, + cacheGetJobsByIds, + cacheSearchKeywords, +} from "../../../../src/lib/cache"; +import { JobSearchRepository } from "../../../../src/modules/jobs/repositories/jobSearch.repository"; +import { parseJobSearchQuery } from "../../../../src/modules/jobs/parsers/jobSearchQuery.parser"; +import { professionalFamilies } from "../../../../src/modules/jobs/types/professionalTaxonomy"; + +const jobs = professionalFamilies.map((f) => ({ + id: f.id, + title: "Senior Engineer", + location: "São Paulo, Brasil", + modality: "Remote", + description: "CLT", + classification: { + primaryFamily: f.id, + relatedFamilies: + f.id === "leadership" + ? ["backend", "devops", "product"] + : f.id === "platform" + ? ["devops"] + : [], + seniority: "senior", + }, +})); +const repository = new JobSearchRepository(); +function seed(data: unknown[]) { + vi.mocked(cacheAbsoluteSMembers).mockResolvedValue( + data.map((j) => (j as { id: string }).id), + ); + vi.mocked(cacheGetJobsByIds).mockImplementation(async (ids) => + ids.flatMap((id) => data.filter((j) => (j as { id: string }).id === id)), + ); +} +async function search(query: Record, page = 1, limit = 100) { + return repository.search(parseJobSearchQuery(query), { page, limit }); +} +beforeEach(() => { + vi.clearAllMocks(); + seed(jobs); +}); +describe("persisted family search", () => { + it.each([ + ["backend", "primary", ["backend"]], + ["backend", "any", ["backend", "leadership"]], + ["backend,fullstack", "primary", ["backend", "fullstack"]], + ["backend,fullstack", "any", ["backend", "fullstack", "leadership"]], + ["fullstack", "primary", ["fullstack"]], + [ + "backend,frontend,fullstack", + "primary", + ["backend", "frontend", "fullstack"], + ], + ["devops", "primary", ["devops"]], + ["platform", "primary", ["platform"]], + ["devops,platform", "primary", ["devops", "platform"]], + ["devops", "any", ["devops", "platform", "leadership"]], + ["product", "primary", ["product"]], + ["product_design", "primary", ["product_design"]], + ["product,product_design", "primary", ["product", "product_design"]], + ])("OR semantics %s / %s", async (family, familyMode, expected) => { + const result = await search({ family, familyMode }); + expect(result.jobs.map((j) => (j as { id: string }).id)).toEqual(expected); + expect(result.total).toBe(expected.length); + }); + it("ANDs product families with seniority, modality, location and contract", async () => { + expect( + ( + await search({ + family: "product,product_design", + familyMode: "primary", + seniority: "senior", + model: "remoto", + city: "São Paulo", + contract: "clt", + }) + ).total, + ).toBe(2); + expect( + (await search({ family: "product,product_design", contract: "pj" })) + .total, + ).toBe(0); + }); + it("uses identical predicate for total and page", async () => { + expect(await search({ family: "backend,fullstack" }, 2, 1)).toEqual({ + total: 3, + jobs: [jobs.find((j) => j.id === "fullstack")], + }); + expect(await search({ family: "backend,fullstack" }, 9, 1)).toEqual({ + total: 3, + jobs: [], + }); + }); + it("keeps reads bounded and counts beyond the page including missing documents", async () => { + const data = Array.from({ length: 605 }, (_, i) => ({ + id: `${i}`, + classification: { primaryFamily: i % 2 ? "backend" : "frontend" }, + })); + seed(data); + vi.mocked(cacheAbsoluteSMembers).mockResolvedValue([ + ...data.map((j) => j.id), + "orphan", + ]); + const result = await search({ family: "backend" }, 2, 3); + expect(result.total).toBe(302); + expect(result.jobs.map((j) => (j as { id: string }).id)).toEqual([ + "7", + "9", + "11", + ]); + expect( + vi + .mocked(cacheGetJobsByIds) + .mock.calls.every(([ids]) => ids.length <= 200), + ).toBe(true); + }); + it("preserves keyword resolution and ANDs it with family", async () => { + vi.mocked(cacheSearchKeywords).mockResolvedValue(["backend", "frontend"]); + expect( + (await search({ keywords: "node,react", family: "backend" })).total, + ).toBe(1); + expect(cacheSearchKeywords).toHaveBeenCalledWith(["node", "react"]); + expect(cacheAbsoluteSMembers).not.toHaveBeenCalled(); + }); + it.each(["asc", "desc"])( + "sorts globally across batches (%s)", + async (matchSort) => { + const data = Array.from({ length: 405 }, (_, i) => ({ + id: `${i}`, + matchScore: i, + classification: { primaryFamily: "backend" }, + })); + seed(data); + const result = await repository.search( + parseJobSearchQuery({ family: "backend", matchSort }), + { page: 2, limit: 2 }, + async (batch) => batch, + ); + expect(result.total).toBe(405); + expect(result.jobs.map((j) => (j as { id: string }).id)).toEqual( + matchSort === "desc" ? ["402", "401"] : ["2", "3"], + ); + }, + ); +}); diff --git a/backend/tests/unit/modules/jobs/searchJobs.service.test.ts b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts index 19f4bad6..f36a5e41 100644 --- a/backend/tests/unit/modules/jobs/searchJobs.service.test.ts +++ b/backend/tests/unit/modules/jobs/searchJobs.service.test.ts @@ -77,7 +77,8 @@ function emptyFilters( ): ParsedJobSearchQuery { return { keywords: [], - family: [], + families: [], + familyMode: "any", technology: [], company: [], seniority: "", @@ -219,152 +220,30 @@ describe("SearchJobsService.execute - legacyResolveIds com keywords", () => { }); }); -describe("SearchJobsService.execute - hasFilters", () => { - it("usa cacheSearchJobIds e retorna verified quando há resultados indexados", async () => { +describe("SearchJobsService - repository boundary", () => { + it.each(["primary", "any"] as const)("passes normalized %s filters and pagination without reinterpretation", async familyMode => { const profileService = buildProfileMatchService(); - const svc = new SearchJobsService(profileService); - + const filters = emptyFilters({ families: ["backend", "fullstack"], familyMode }); + mockParseJobSearchQuery.mockReturnValue(filters); 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", - }), - ); - 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 repository = { search: vi.fn().mockResolvedValue({ jobs: [{ id: "a" }], total: 21 }) }; + const svc = new SearchJobsService(profileService, repository); const result = await svc.execute({ userId: "u1", query: {} }); - - expect(result.source).toContain("legacy_post_filter_fallback"); - expect(mockCacheSearchKeywords).toHaveBeenCalledWith(["react"]); + expect(repository.search).toHaveBeenCalledWith(filters, defaultPagination, undefined); + expect(result).toMatchObject({ total: 21, page: 1, limit: 10, totalPages: 3, hasNext: true }); + expect(mockCacheSearchJobIds).not.toHaveBeenCalled(); }); -}); - -describe("SearchJobsService.execute - hasPostOnlyFilters", () => { - it("aplica filterJobs e retorna post_filter", async () => { + it("scores batches without notifications and enriches only the final page normally", async () => { const profileService = buildProfileMatchService(); - const svc = new SearchJobsService(profileService); - - 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 svc.execute({ userId: "u1", query: {} }); - - expect(mockFilterJobs).toHaveBeenCalled(); - expect(result.source).toContain("post_filter"); - expect(mockSortJobsByMatch).toHaveBeenCalled(); - }); -}); - -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, - }, - }); - - const result = await svc.execute({ userId: "u1", query: {} }); - - 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); - - 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 svc.execute({ userId: "u1", query: {} }); - - expect(profileService.enrich).toHaveBeenCalledTimes(2); - expect(result.total).toBe(2); + mockParseJobSearchQuery.mockReturnValue(emptyFilters({ matchSort: "asc" })); + const repository = { search: vi.fn().mockImplementation(async (_f, _p, enrich) => { + await enrich([{ id: "candidate" }]); + return { jobs: [{ id: "page" }], total: 20 }; + }) }; + const result = await new SearchJobsService(profileService, repository).execute({ query: {}, userId: "u1" }); + expect(profileService.enrich).toHaveBeenNthCalledWith(1, "u1", [{ id: "candidate" }], [], { notifyHighMatches: false }); + expect(profileService.enrich).toHaveBeenNthCalledWith(2, "u1", [{ id: "page" }], []); + expect(result.total).toBe(20); }); }); @@ -593,37 +472,13 @@ describe("searchJobsService (singleton)", () => { }); describe("toSearchResult - shape", () => { - it("mapeia pagination para o resultado", async () => { + it("mapeia paginação calculada sobre os resultados filtrados", async () => { const profileService = buildProfileMatchService(); - const svc = new SearchJobsService(profileService); - + const repository = { search: vi.fn().mockResolvedValue({ jobs: [{ id: "a" }], total: 5 }) }; + mockParsePagination.mockReturnValue({ page: 2, limit: 3 }); 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, - }); + mockParseJobSearchQuery.mockReturnValue(emptyFilters({ families: ["backend"] })); + const result = await new SearchJobsService(profileService, repository).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/swagger.test.ts b/backend/tests/unit/swagger.test.ts index eee68307..fd6190f2 100644 --- a/backend/tests/unit/swagger.test.ts +++ b/backend/tests/unit/swagger.test.ts @@ -63,3 +63,26 @@ describe("swagger", () => { expect(spec.paths).not.toHaveProperty("/api/keywords"); }); }); + +describe("PAV-124 OpenAPI contract", () => { + it("validates the complete OpenAPI document and references", async () => { + const { default: SwaggerParser } = await import("@apidevtools/swagger-parser"); + await expect(SwaggerParser.validate(JSON.parse(JSON.stringify(swaggerSpec)))).resolves.toBeDefined(); + }); + it("uses canonical enums, serializations, mode default, errors and cache headers", async () => { + const { professionalFamilies } = await import("../../src/modules/jobs/types/professionalTaxonomy"); + const spec = swaggerSpec as SwaggerSpec; + const search = spec.paths?.["/jobs/search"]?.get; + const family = search.parameters.find((p: { name: string }) => p.name === "family"); + expect(family.schema.items.enum).toEqual(professionalFamilies.map(f => f.id)); + expect(family).toMatchObject({ style: "form", explode: true, schema: { maxItems: 13 } }); + expect(family.description).toContain("family=backend,fullstack"); + expect(family.description).toContain("family=backend&family=fullstack"); + expect(search.parameters.find((p: { name: string }) => p.name === "familyMode").schema).toEqual({ type: "string", enum: ["primary", "any"], default: "any" }); + expect(search.responses[400].content["application/json"].examples.family.value.code).toBe("INVALID_JOB_FAMILY"); + expect(search.responses[400].content["application/json"].examples.mode.value.code).toBe("INVALID_FAMILY_MODE"); + const options = spec.paths?.["/jobs/filters/options"]?.get; + expect(options.responses[200].headers["Cache-Control"].example).toBe("public, max-age=3600"); + expect(options.responses[304]).toBeDefined(); + }); +});