Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5a80b8c
feat(PAV-178): adiciona baseline e observabilidade detalhada do proce…
hltav Oct 12, 2026
7f3ac75
perf(PAV-178): otimiza geração de chaves de indexação
hltav Oct 12, 2026
0389c1e
perf(PAV-178): otimiza classificação e detalha timings do pipeline
hltav Oct 12, 2026
dc00b61
perf(PAV-178): otimiza persistencia com deteccao de mudancas
hltav Oct 12, 2026
afb25b8
fix(PAV-178): implementa seleção efetiva por provedor
hltav Nov 1, 2026
fa6d658
feat(PAV-178): adiciona controle de ativação do scheduler automático
hltav Nov 1, 2026
4c47dfd
fix(PAV-178): preserva resultados parciais durante falhas de coleta
hltav Nov 1, 2026
a2c53ba
fix(PAV-178): bloqueia seeds ficticios no deploy
hltav Nov 1, 2026
4643420
fix(PAV-178): finaliza timeout configuravel e status parcial
hltav Nov 1, 2026
b394848
feat(PAV-178): agenda provedores e retoma lotes de Greenhouse
hltav Nov 1, 2026
07f8095
fix(PAV-178): respeita Retry-After e renova cache por execucao
hltav Nov 1, 2026
68d6ef1
chore(PAV-178): valida regressões e documenta publicação em DEV
hltav Nov 1, 2026
bf554a5
feat(PAV-178): implementa scheduler automático com 21 janelas
hltav Nov 2, 2026
284d1c9
feat(PAV-178): fixa grupos Greenhouse por janela
hltav Nov 2, 2026
85793e5
feat(PAV-178): implementa deadline e controle horario do scheduler
hltav Nov 2, 2026
814026a
feat(PAV-178): implementa cobertura completa de keywords por janela
hltav Nov 2, 2026
9fad872
feat(PAV-178): otimiza Gupy e fortalece controle dos providers
hltav Nov 2, 2026
95e4e34
fix(PAV-178): valida integracao e corrige admissao tardia do scheduler
hltav Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const ProcessorSnapshotSchema = z.object({
status: z.enum(["ok", "partial"]),
timestamp: z.string().datetime(),
execution: z.object({
partial: z.boolean().optional(),
runId: z.string().min(1).max(128).nullable().optional().default(null),
status: z.enum(["idle", "running", "canceling", "failed", "completed"]),
source: z.enum(["manual", "cron"]).nullable(),
Expand Down
30 changes: 25 additions & 5 deletions backend/src/modules/admin/scrapers/scraperClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JobsCountResultSchema } from "./scrapers.types";
import { JobsCountResultSchema, ScraperStatusSchema } from "./scrapers.types";
import { config } from "../../../config";
import type {
GetJobsResult,
Expand All @@ -10,6 +10,15 @@ import type {

const TIMEOUT_MS = 5000;

export class ScraperInvalidSelectionError extends Error {
readonly code = "INVALID_PROVIDER_SELECTION";
}

export class ScraperRateLimitedError extends Error {
readonly code = "PROVIDER_RATE_LIMITED";
constructor(message: string, readonly retryAfter: string | null) { super(message); }
}

/** Lançado quando o Go scraper responde 409 (já em execução) */
export class ScraperAlreadyRunningError extends Error {
readonly code = "SCRAPER_ALREADY_RUNNING";
Expand Down Expand Up @@ -41,6 +50,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const body = await response.json().catch(() => null);
throw new ScraperAlreadyRunningError(body?.message);
}
if (response.status === 400) {
const body = await response.json().catch(() => null);
throw new ScraperInvalidSelectionError(body?.message ?? "Seleção de provedor inválida.");
}
if (response.status === 429) {
const body = await response.json().catch(() => null);
throw new ScraperRateLimitedError(body?.message ?? "Provedor aguardando Retry-After.", response.headers?.get("Retry-After") ?? null);
}

if (response.status === 503) {
const body = await response.json().catch(() => null);
Expand All @@ -55,12 +72,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}

export const scraperClient = {
triggerScrape(): Promise<TriggerScrapeResult> {
return request<TriggerScrapeResult>("/admin/scrape", { method: "POST" });
triggerScrape(sources?: string[]): Promise<TriggerScrapeResult> {
return request<TriggerScrapeResult>("/admin/scrape", {
method: "POST",
...(sources !== undefined ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sources }) } : {}),
});
},

getStatus(): Promise<ScraperStatus> {
return request<ScraperStatus>("/admin/scrape/status");
async getStatus(): Promise<ScraperStatus> {
return ScraperStatusSchema.parse(await request<unknown>("/admin/scrape/status"));
},

getJobs(limit?: number): Promise<GetJobsResult> {
Expand Down
17 changes: 16 additions & 1 deletion backend/src/modules/admin/scrapers/scrapers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type { AuditService } from "../audit/audit.service";
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
ScraperInvalidSelectionError,
ScraperRateLimitedError,
} from "./scraperClient";
import { ScrapersService } from "./scrapers.service";
import { TriggerScrapeRequestSchema } from "./scrapers.types";

export class ScrapersController {
constructor(
Expand All @@ -13,13 +16,20 @@ export class ScrapersController {
) {}

async trigger(req: Request, res: Response): Promise<void> {
const selection = TriggerScrapeRequestSchema.safeParse(req.body ?? {});
if (!selection.success) { res.status(400).json({ ok: false, code: "INVALID_PROVIDER_SELECTION" }); return; }
try {
const result = await this.scrapersService.triggerScrape();
const result = await this.scrapersService.triggerScrape(selection.data.sources);

this.auditService.fromRequest(req, "scrapers.trigger");

res.status(202).json(result);
} catch (error) {
if (error instanceof ScraperRateLimitedError) {
if (error.retryAfter) res.setHeader("Retry-After", error.retryAfter);
res.status(429).json({ ok: false, code: error.code, message: error.message }); return;
}
if (error instanceof ScraperInvalidSelectionError) { res.status(400).json({ ok: false, code: error.code, message: error.message }); return; }
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({
ok: false,
Expand Down Expand Up @@ -52,6 +62,11 @@ export class ScrapersController {

res.status(202).json({ ...result, scraper: scraperName });
} catch (error) {
if (error instanceof ScraperRateLimitedError) {
if (error.retryAfter) res.setHeader("Retry-After", error.retryAfter);
res.status(429).json({ ok: false, code: error.code, message: error.message }); return;
}
if (error instanceof ScraperInvalidSelectionError) { res.status(400).json({ ok: false, code: error.code, message: error.message }); return; }
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({
ok: false,
Expand Down
13 changes: 9 additions & 4 deletions backend/src/modules/admin/scrapers/scrapers.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
ScraperInvalidSelectionError,
ScraperRateLimitedError,
scraperClient,
} from "./scraperClient";
import type {
Expand All @@ -11,15 +13,16 @@ import type {
ScraperStatus,
TriggerScrapeResult,
} from "./scrapers.types";
import { TriggerScrapeRequestSchema } from "./scrapers.types";

export class ScrapersService {
async triggerScrape(): Promise<TriggerScrapeResult> {
async triggerScrape(sources?: string[]): Promise<TriggerScrapeResult> {
try {
return await scraperClient.triggerScrape();
return await scraperClient.triggerScrape(sources);
} catch (error) {
if (
error instanceof ScraperAlreadyRunningError ||
error instanceof ScraperRunLockUnavailableError
error instanceof ScraperRunLockUnavailableError || error instanceof ScraperInvalidSelectionError || error instanceof ScraperRateLimitedError
) {
// repropaga para o controller decidir o status HTTP (409)
throw error;
Expand All @@ -31,7 +34,9 @@ export class ScrapersService {
async triggerScraper(name: string): Promise<TriggerScrapeResult> {
const normalized = name.trim().toLowerCase();
if (normalized !== "go-scraper") {
throw new Error(`scraper desconhecido: ${name}`);
const selection = TriggerScrapeRequestSchema.safeParse({ sources: [normalized] });
if (!selection.success) throw new Error(`scraper desconhecido: ${name}`);
return this.triggerScrape(selection.data.sources);
}

return this.triggerScrape();
Expand Down
27 changes: 26 additions & 1 deletion backend/src/modules/admin/scrapers/scrapers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ export const TriggerScrapeResultSchema = z.object({
});
export type TriggerScrapeResult = z.infer<typeof TriggerScrapeResultSchema>;

export const TriggerScrapeRequestSchema = z.object({
sources: z.array(z.enum(["linkedin", "adzuna", "gupy", "inhire", "greenhouse", "lever", "jooble", "themuse"])).max(8).optional(),
}).strict();

export const TriggerScrapeErrorSchema = z.object({
ok: z.literal(false),
code: z.enum([
"SCRAPER_ALREADY_RUNNING",
"SCRAPER_RUN_LOCK_UNAVAILABLE",
"SCRAPER_RUN_LOCK_LOST",
"INVALID_PROVIDER_SELECTION",
"PROVIDER_RATE_LIMITED",
]),
message: z.string(),
});
Expand All @@ -40,8 +46,27 @@ export type TriggerScrapeError = z.infer<typeof TriggerScrapeErrorSchema>;
export const ScraperStatusSchema = z.object({
name: z.string().optional(),
running: z.boolean(),
lastRunAt: z.string().optional(),
lastRunAt: z.string().nullable().optional(),
jobsCollected: z.number().int().min(0).optional(),
lastRun: z.object({
runId: z.string(),
providers: z.array(z.string()).nullable().optional(),
status: z.enum(["success", "partial", "failed", "timeout", "canceled"]),
finishedAt: z.string().datetime(),
jobsSaved: z.number().int().nonnegative(),
jobsIndexed: z.number().int().nonnegative(),
errorType: z.string(),
}).nullable().optional(),
providerWindows: z.array(z.union([
z.object({
provider: z.string(), id: z.string().optional(), timezone: z.literal("UTC").optional(),
startMinuteUTC: z.number().int().min(0).max(1439), endMinuteUTC: z.number().int().min(1).max(1440),
}),
z.object({
provider: z.string(), id: z.string(), timezone: z.string().min(1),
startMinuteLocal: z.number().int().min(0).max(1439), endMinuteLocal: z.number().int().min(1).max(1440),
}),
])).nullable().optional(),
});
export type ScraperStatus = z.infer<typeof ScraperStatusSchema>;

Expand Down
2 changes: 2 additions & 0 deletions backend/src/scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as argon2 from "argon2";
import { assertLocalSeedAllowed } from "./seedPolicy";
import "dotenv/config";
import { and, eq } from "drizzle-orm";
import { db, pool } from "../db/client";
Expand Down Expand Up @@ -237,6 +238,7 @@ async function upsertApplicationEvent(


async function main() {
assertLocalSeedAllowed();
console.log("Iniciando seed de desenvolvimento local...");

const devUserSpec = SEED_USERS.find((spec) => spec.role === "user")!;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/scripts/seedCatalogJobs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ProfessionalFamily } from "../modules/jobs/types/professionalTaxonomy";
import { assertLocalSeedAllowed } from "./seedPolicy";
import { createClient } from "redis";

export type SeedTechnologyExperience = {
Expand Down Expand Up @@ -391,6 +392,7 @@ async function connectSeedCache(url: string) {
}

export async function seedCatalogJobs(): Promise<void> {
assertLocalSeedAllowed();
const url = process.env.VALKEY_URL;

if (!url) {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/scripts/seedPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Demo users, saved jobs and catalog fixtures are only permitted in an
// explicitly opted-in local environment. NODE_ENV=development also occurs in DEV.
export function assertLocalSeedAllowed(env = process.env): void {
if (
env.APP_ENV !== "local" ||
env.ALLOW_DEMO_SEED !== "true" ||
env.NODE_ENV === "production"
) {
throw new Error(
"Seed fictício bloqueado: exige APP_ENV=local, ALLOW_DEMO_SEED=true e NODE_ENV diferente de production.",
);
}
}
38 changes: 38 additions & 0 deletions backend/tests/unit/modules/admin/providerDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { afterEach, expect, it, vi } from "vitest";
import { scraperClient, ScraperInvalidSelectionError, ScraperRateLimitedError } from "../../../../src/modules/admin/scrapers/scraperClient";
import { ScrapersService } from "../../../../src/modules/admin/scrapers/scrapers.service";
import { ScrapersController } from "../../../../src/modules/admin/scrapers/scrapers.controller";
import type { Request, Response } from "express";

afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });

it("preserves Retry-After when a provider is temporarily blocked", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 429, headers: { get: () => "120" }, json: async () => ({ message: "rate limited" }) }));
await expect(new ScrapersService().triggerScraper("greenhouse")).rejects.toBeInstanceOf(ScraperRateLimitedError);
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), setHeader: vi.fn() } as unknown as Response;
await new ScrapersController(new ScrapersService(), { fromRequest: vi.fn() } as never).triggerOne({ params: { id: "greenhouse" } } as unknown as Request, res);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", "120");
});

it("forwards an individual ProviderID to Go without a global fallback", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true, message: "started" }) });
vi.stubGlobal("fetch", fetch);
await new ScrapersService().triggerScraper("greenhouse");
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ sources: ["greenhouse"] });
});

it("preserves a Go rejection when the provider is disabled", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: "provider is not configured" }) }));
await expect(new ScrapersService().triggerScraper("greenhouse")).rejects.toBeInstanceOf(ScraperInvalidSelectionError);
});

it("rejects invalid selection before sending any request to Go", async () => {
const trigger = vi.spyOn(scraperClient, "triggerScrape");
const service = new ScrapersService();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
const controller = new ScrapersController(service, { fromRequest: vi.fn() } as never);
await controller.trigger({ body: { sources: ["unknown"] } } as Request, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(trigger).not.toHaveBeenCalled();
});
24 changes: 24 additions & 0 deletions backend/tests/unit/modules/admin/providerSchedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { ScraperStatusSchema } from "../../../../src/modules/admin/scrapers/scrapers.types";

describe("provider window status contract", () => {
it("preserves legacy UTC windows and manual status responses", () => {
expect(ScraperStatusSchema.parse({ running: false })).toEqual({ running: false });
const window = { provider: "gupy", startMinuteUTC: 60, endMinuteUTC: 120 };
expect(ScraperStatusSchema.parse({ running: false, providerWindows: [window] }).providerWindows).toEqual([window]);
});

it("preserves local window identity and timezone", () => {
const window = { provider: "greenhouse", id: "greenhouse-1", timezone: "America/Sao_Paulo", startMinuteLocal: 0, endMinuteLocal: 60 };
expect(ScraperStatusSchema.parse({ running: true, providerWindows: [window] }).providerWindows).toEqual([window]);
});

it("rejects incomplete local windows and invalid minute bounds", () => {
for (const window of [
{ provider: "gupy", startMinuteLocal: 0, endMinuteLocal: 60 },
{ provider: "gupy", id: "gupy-1", timezone: "America/Sao_Paulo", startMinuteLocal: 1440, endMinuteLocal: 1500 },
]) {
expect(ScraperStatusSchema.safeParse({ running: false, providerWindows: [window] }).success).toBe(false);
}
});
});
14 changes: 14 additions & 0 deletions backend/tests/unit/modules/admin/scraperResult.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, it } from "vitest";
import { ScraperStatusSchema } from "../../../../src/modules/admin/scrapers/scrapers.types";

it("retains partial execution counts and accepts an empty initial history", () => {
expect(ScraperStatusSchema.parse({ running: false, lastRunAt: null, lastRun: null }).lastRun).toBeNull();
const result = ScraperStatusSchema.parse({
running: false,
lastRunAt: "2026-11-01T00:00:00Z",
jobsCollected: 210,
lastRun: { runId: "test", status: "partial", finishedAt: "2026-11-01T00:00:00Z", jobsSaved: 210, jobsIndexed: 210, errorType: "timeout" },
});
expect(result.lastRun?.status).toBe("partial");
expect(result.lastRun?.jobsIndexed).toBe(210);
});
4 changes: 3 additions & 1 deletion backend/tests/unit/modules/admin/scrapers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ describe("ScrapersService", () => {
ok: true,
message: "started",
});
await expect(service.triggerScraper("lever")).rejects.toThrow(
await expect(service.triggerScraper("lever")).resolves.toEqual({ ok: true, message: "started" });
expect(scraperClient.triggerScrape).toHaveBeenLastCalledWith(["lever"]);
await expect(service.triggerScraper("invalid")).rejects.toThrow(
"scraper desconhecido",
);
});
Expand Down
21 changes: 20 additions & 1 deletion backend/tests/unit/scripts/seed.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const seedMocks = vi.hoisted(() => ({
applicationEventsFindFirst: vi.fn(),
Expand Down Expand Up @@ -34,6 +34,9 @@ const originalArgv = [...process.argv];

describe("seed script", () => {
beforeEach(() => {
vi.stubEnv("APP_ENV", "local");
vi.stubEnv("ALLOW_DEMO_SEED", "true");
vi.stubEnv("NODE_ENV", "test");
vi.resetModules();
vi.clearAllMocks();
process.argv = [...originalArgv];
Expand All @@ -56,6 +59,22 @@ describe("seed script", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
process.exitCode = 0;
});

it("blocks DEV fixtures before any database or catalog write", async () => {
vi.stubEnv("APP_ENV", "dev");
await import("../../../src/scripts/seed");
await vi.waitFor(() => expect(seedMocks.poolEnd).toHaveBeenCalledOnce());
expect(seedMocks.credentialsFindFirst).not.toHaveBeenCalled();
expect(seedMocks.update).not.toHaveBeenCalled();
expect(seedMocks.insert).not.toHaveBeenCalled();
expect(seedMocks.seedCatalogJobs).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});

it("preserves existing records while refreshing profile and catalog data", async () => {
await import("../../../src/scripts/seed");

Expand Down
16 changes: 16 additions & 0 deletions backend/tests/unit/scripts/seedPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { assertLocalSeedAllowed } from "../../../src/scripts/seedPolicy";

describe("demo seed policy", () => {
it.each([undefined, "dev", "development", "staging", "production"])(
"rejects deployment environment %s despite opt-in",
(APP_ENV) => {
expect(() => assertLocalSeedAllowed({ APP_ENV, ALLOW_DEMO_SEED: "true" })).toThrow();
},
);
it("requires local opt-in and rejects production runtime", () => {
expect(() => assertLocalSeedAllowed({ APP_ENV: "local" })).toThrow();
expect(() => assertLocalSeedAllowed({ APP_ENV: "local", ALLOW_DEMO_SEED: "true", NODE_ENV: "production" })).toThrow();
expect(() => assertLocalSeedAllowed({ APP_ENV: "local", ALLOW_DEMO_SEED: "true", NODE_ENV: "development" })).not.toThrow();
});
});
2 changes: 1 addition & 1 deletion backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"ignoreDeprecations": "6.0",
"ignoreDeprecations": "5.0",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
Expand Down
Loading
Loading