Skip to content

Commit 0a8944b

Browse files
authored
Feature/pav 83 dashboard usuario cobertura testes (#177)
2 parents eba53dc + 694c4b8 commit 0a8944b

84 files changed

Lines changed: 7719 additions & 88 deletions

File tree

Some content is hidden

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

.cspell/custom-dictionary-workspace.txt

Lines changed: 122 additions & 0 deletions
Large diffs are not rendered by default.

backend/bruno/Auth/Register.bru

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ post {
1212

1313
body:json {
1414
{
15-
"email":"hudson60@gmail.com",
16-
"password": "tavares10",
17-
"name": "Hudson Tavares",
18-
"role":"banana"
15+
"email": "teste.criptografia.001@example.com",
16+
"password": "SenhaForte123",
17+
"name": "Teste Criptografia",
18+
"phone": "+5534999999999",
19+
"cpf": "12345678901",
20+
"technologies": ["TypeScript", "Node.js", "React"],
21+
"level": "pleno"
1922
}
2023
}
2124

backend/bruno/Jobs/SearchJobs.bru

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ meta {
55
}
66

77
get {
8-
url: {{base_url}}/jobs/search?keywords=node.js
8+
url: {{base_url}}/jobs/search?location=&keywords=node.js
99
body: none
1010
auth: inherit
1111
}
1212

1313
params:query {
14+
location:
1415
keywords: node.js
1516
}
1617

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 () => {

front_admin/src/app/layouts/MainLayout/Sidebar/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useNavigate } from "react-router-dom";
2+
import { CandidateLogo } from "../../../../lib/ui/CandidateLogo";
23
import { useAuth } from "../../../../modules/auth/hooks/useAuth";
3-
import { CandidateLogo } from "../components/CandidateLogo";
44
import { SidebarFooter } from "./components/SidebarFooter";
55
import { SidebarItem } from "./components/SidebarItem";
66
import { NAV_ITEMS } from "./sidebar.config";

front_admin/src/app/layouts/MainLayout/components/CandidateLogo.tsx renamed to front_admin/src/lib/ui/CandidateLogo.tsx

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,3 @@
1-
// interface CandidateLogoProps {
2-
// size?: "sm" | "md";
3-
// }
4-
5-
// const SIZE_STYLES = {
6-
// sm: "text-xl",
7-
// md: "text-2xl",
8-
// };
9-
10-
// export function CandidateLogo({ size = "md" }: CandidateLogoProps) {
11-
// return (
12-
// <span
13-
// aria-label="Cand!Date!"
14-
// className={`inline-flex items-baseline whitespace-nowrap font-extrabold tracking-normal ${SIZE_STYLES[size]}`}
15-
// >
16-
// <span className="text-[#2f6fff] dark:text-[##2D5CAB]">&lt;</span>
17-
// <span className="text-slate-950 dark:text-slate-100">Cand!Date</span>
18-
// <span className="text-[#2f6fff] dark:text-[#2D5CAB]">!&gt;</span>
19-
// </span>
20-
// );
21-
// }
221
interface CandidateLogoProps {
232
size?: "sm" | "md";
243
}

front_admin/src/modules/auth/LoginPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Lock } from "lucide-react";
22
import { useNavigate } from "react-router-dom";
3-
import { CandidateLogo } from "../../app/layouts/MainLayout/components/CandidateLogo";
43
import { ErrorMessage } from "../../components/common/ErrorMessage";
4+
import { CandidateLogo } from "../../lib/ui/CandidateLogo";
55
import { BrandPanel } from "./components/BrandPanel";
66
import { LoginForm } from "./components/LoginForm";
77
import { LoginHeader } from "./components/LoginHeader";

front_admin/tsconfig.app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@
2222
"erasableSyntaxOnly": true,
2323
"noFallthroughCasesInSwitch": true
2424
},
25-
"include": ["src"]
25+
"include": ["src", "../shared/CandidateLogo.tsx"]
2626
}

frontend/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineConfig([
1919
ecmaVersion: 2020,
2020
globals: globals.browser,
2121
parserOptions: {
22+
tsconfigRootDir: import.meta.dirname,
2223
ecmaVersion: 'latest',
2324
ecmaFeatures: { jsx: true },
2425
sourceType: 'module',

0 commit comments

Comments
 (0)