Skip to content

Commit fc7d156

Browse files
committed
feat(new-dashboard): cria new dashboard e amplia cobertura de testes
1 parent 7777048 commit fc7d156

69 files changed

Lines changed: 7467 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/routes/jobs.routes.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,105 @@ import { logWarn } from "../logger";
99

1010
export const jobsRoutes = Router();
1111

12+
type SearchJob = {
13+
title?: string | null;
14+
location?: string | null;
15+
modality?: string | null;
16+
description?: string | null;
17+
};
18+
19+
function firstQueryValue(value: unknown): string {
20+
if (Array.isArray(value)) return firstQueryValue(value[0]);
21+
return typeof value === "string" ? value.trim() : "";
22+
}
23+
24+
function normalizeComparable(value: string): string {
25+
return value
26+
.normalize("NFD")
27+
.replace(/[\u0300-\u036f]/g, "")
28+
.toLowerCase()
29+
.trim();
30+
}
31+
32+
function inferJobLevel(title: string): string {
33+
const normalized = normalizeComparable(title);
34+
if (normalized.includes("senior") || normalized.includes("sr")) {
35+
return "senior";
36+
}
37+
if (normalized.includes("junior") || normalized.includes("jr")) {
38+
return "junior";
39+
}
40+
return "pleno";
41+
}
42+
43+
function inferJobType(job: SearchJob): string {
44+
const normalized = normalizeComparable(
45+
[
46+
job.title,
47+
job.location,
48+
job.modality,
49+
job.description,
50+
]
51+
.filter(Boolean)
52+
.join(" "),
53+
);
54+
55+
if (normalized.includes("hibrid") || normalized.includes("hybrid")) {
56+
return "hibrido";
57+
}
58+
if (
59+
normalized.includes("remot") ||
60+
normalized.includes("home office") ||
61+
normalized.includes("teletrabalho") ||
62+
normalized.includes("anywhere") ||
63+
normalized.includes("worldwide")
64+
) {
65+
return "remoto";
66+
}
67+
if (
68+
normalized.includes("presencial") ||
69+
normalized.includes("onsite") ||
70+
normalized.includes("on site") ||
71+
normalized.includes("on-site") ||
72+
normalized.includes("in office") ||
73+
normalized.includes("escritorio")
74+
) {
75+
return "presencial";
76+
}
77+
78+
return "presencial";
79+
}
80+
81+
function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] {
82+
const level = normalizeComparable(firstQueryValue(query.level));
83+
const location = normalizeComparable(firstQueryValue(query.location));
84+
const type = normalizeComparable(firstQueryValue(query.type));
85+
86+
if (!level && !location && !type) return jobs;
87+
88+
return jobs.filter((job) => {
89+
const candidate = job as SearchJob;
90+
const title = candidate.title ?? "";
91+
const jobLocation = candidate.location ?? "";
92+
const normalizedLocation = normalizeComparable(jobLocation);
93+
94+
const matchesLevel = !level || inferJobLevel(title) === level;
95+
const matchesLocation =
96+
!location || normalizedLocation.includes(location);
97+
const matchesType = !type || inferJobType(candidate) === type;
98+
99+
return matchesLevel && matchesLocation && matchesType;
100+
});
101+
}
102+
103+
function hasPostFetchFilters(query: Request["query"]): boolean {
104+
return Boolean(
105+
firstQueryValue(query.level) ||
106+
firstQueryValue(query.location) ||
107+
firstQueryValue(query.type),
108+
);
109+
}
110+
12111
/**
13112
* @swagger
14113
* /api/jobs/search:
@@ -42,8 +141,25 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
42141
ids = await cacheAbsoluteSMembers("scraper:jobs:index");
43142
}
44143

45-
const { data: pageIds, pagination: meta } = paginate(ids, pagination);
46-
const jobs = await cacheGetJobsByIds(pageIds);
144+
if (!hasPostFetchFilters(req.query)) {
145+
const { data: pageIds, pagination: meta } = paginate(ids, pagination);
146+
const jobs = await cacheGetJobsByIds(pageIds);
147+
148+
return res.json({
149+
total: meta.total,
150+
page: meta.page,
151+
limit: meta.limit,
152+
totalPages: meta.totalPages,
153+
hasNext: meta.hasNext,
154+
hasPrev: meta.hasPrev,
155+
jobs,
156+
source,
157+
});
158+
}
159+
160+
const allJobs = await cacheGetJobsByIds(ids);
161+
const filteredJobs = filterJobs(allJobs, req.query);
162+
const { data: jobs, pagination: meta } = paginate(filteredJobs, pagination);
47163

48164
return res.json({
49165
total: meta.total,

backend/tests/unit/app.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,69 @@ describe("jobsApiApp", () => {
235235
expect(res.body.source).toBe("valkey_global_index");
236236
});
237237

238+
it("GET /jobs/search aplica filtros de level, location e type", async () => {
239+
mocks.cacheGetJobsByIds.mockResolvedValue([
240+
{
241+
id: "id-1",
242+
title: "Desenvolvedor Node.js Júnior",
243+
company: "ACME",
244+
location: "Brasil - Remoto",
245+
},
246+
{
247+
id: "id-2",
248+
title: "Desenvolvedor Node.js Sênior",
249+
company: "Globo",
250+
location: "Estados Unidos - Remoto",
251+
},
252+
{
253+
id: "id-3",
254+
title: "Desenvolvedor Node.js Pleno",
255+
company: "Globex",
256+
location: "Brasil - Hybrid Remote",
257+
},
258+
]);
259+
260+
const app = createJobsApiApp();
261+
const res = await request(app)
262+
.get("/jobs/search")
263+
.query({
264+
keywords: "Node.js",
265+
level: "Júnior",
266+
location: "Brasil",
267+
type: "Remoto",
268+
})
269+
.expect(200);
270+
271+
expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-1" })]);
272+
expect(res.body.total).toBe(1);
273+
});
274+
275+
it("GET /jobs/search diferencia modelo híbrido de remoto", async () => {
276+
mocks.cacheGetJobsByIds.mockResolvedValue([
277+
{
278+
id: "id-1",
279+
title: "Desenvolvedor Node.js",
280+
company: "ACME",
281+
location: "Brasil - Remote",
282+
},
283+
{
284+
id: "id-2",
285+
title: "Desenvolvedor Node.js",
286+
company: "Globex",
287+
location: "Brasil - Hybrid Remote",
288+
},
289+
]);
290+
291+
const app = createJobsApiApp();
292+
const res = await request(app)
293+
.get("/jobs/search")
294+
.query({ keywords: "Node.js", type: "Híbrido" })
295+
.expect(200);
296+
297+
expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-2" })]);
298+
expect(res.body.total).toBe(1);
299+
});
300+
238301
it("GET /jobs/search retorna paginação correta", async () => {
239302
mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 });
240303
mocks.paginate.mockReturnValue({
@@ -288,6 +351,71 @@ describe("jobsApiApp", () => {
288351
expect(res.body.message).toBe("Erro ao recuperar vagas em memória.");
289352
});
290353

354+
it("GET /jobs/search trata filtros em array e aplica o primeiro valor", async () => {
355+
mocks.cacheGetJobsByIds.mockResolvedValue([
356+
{
357+
id: "id-array",
358+
title: "Desenvolvedor Node.js Júnior",
359+
company: "ACME",
360+
location: "Brasil - Remoto",
361+
},
362+
]);
363+
364+
const app = createJobsApiApp();
365+
const res = await request(app)
366+
.get("/jobs/search")
367+
.query({
368+
keywords: "Node.js",
369+
level: ["Júnior", "Sênior"],
370+
location: ["Brasil", "Portugal"],
371+
type: ["Remoto", "Híbrido"],
372+
})
373+
.expect(200);
374+
375+
expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-array" })]);
376+
expect(res.body.total).toBe(1);
377+
});
378+
379+
it("GET /jobs/search identifica vaga sênior presencial", async () => {
380+
mocks.cacheGetJobsByIds.mockResolvedValue([
381+
{
382+
id: "id-senior",
383+
title: "Tech Lead Sênior",
384+
company: "ACME",
385+
location: "São Paulo - Onsite",
386+
},
387+
]);
388+
389+
const app = createJobsApiApp();
390+
const res = await request(app)
391+
.get("/jobs/search")
392+
.query({ keywords: "Tech Lead", type: "Presencial" })
393+
.expect(200);
394+
395+
expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-senior" })]);
396+
expect(res.body.total).toBe(1);
397+
});
398+
399+
it("GET /jobs/search usa o fallback de tipo presencial quando não há pistas de remoto", async () => {
400+
mocks.cacheGetJobsByIds.mockResolvedValue([
401+
{
402+
id: "id-fallback",
403+
title: "Backend Developer",
404+
company: "ACME",
405+
location: "Brasil",
406+
},
407+
]);
408+
409+
const app = createJobsApiApp();
410+
const res = await request(app)
411+
.get("/jobs/search")
412+
.query({ keywords: "Backend", type: "Presencial" })
413+
.expect(200);
414+
415+
expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-fallback" })]);
416+
expect(res.body.total).toBe(1);
417+
});
418+
291419
// ── keywords ──────────────────────────────────────────────────────────
292420

293421
it("GET /keywords retorna keywords do banco", async () => {

0 commit comments

Comments
 (0)