Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 122 additions & 0 deletions .cspell/custom-dictionary-workspace.txt

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions backend/bruno/Auth/Register.bru
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ post {

body:json {
{
"email":"hudson60@gmail.com",
"password": "tavares10",
"name": "Hudson Tavares",
"role":"banana"
"email": "teste.criptografia.001@example.com",
"password": "SenhaForte123",
"name": "Teste Criptografia",
"phone": "+5534999999999",
"cpf": "12345678901",
"technologies": ["TypeScript", "Node.js", "React"],
"level": "pleno"
}
}

Expand Down
3 changes: 2 additions & 1 deletion backend/bruno/Jobs/SearchJobs.bru
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ meta {
}

get {
url: {{base_url}}/jobs/search?keywords=node.js
url: {{base_url}}/jobs/search?location=&keywords=node.js
body: none
auth: inherit
}

params:query {
location:
keywords: node.js
}

Expand Down
120 changes: 118 additions & 2 deletions backend/src/routes/jobs.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,105 @@ import { logWarn } from "../logger";

export const jobsRoutes = Router();

type SearchJob = {
title?: string | null;
location?: string | null;
modality?: string | null;
description?: string | null;
};

function firstQueryValue(value: unknown): string {
if (Array.isArray(value)) return firstQueryValue(value[0]);
return typeof value === "string" ? value.trim() : "";
}

function normalizeComparable(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}

function inferJobLevel(title: string): string {
const normalized = normalizeComparable(title);
if (normalized.includes("senior") || normalized.includes("sr")) {
return "senior";
}
if (normalized.includes("junior") || normalized.includes("jr")) {
return "junior";
}
return "pleno";
}

function inferJobType(job: SearchJob): string {
const normalized = normalizeComparable(
[
job.title,
job.location,
job.modality,
job.description,
]
.filter(Boolean)
.join(" "),
);

if (normalized.includes("hibrid") || normalized.includes("hybrid")) {
return "hibrido";
}
if (
normalized.includes("remot") ||
normalized.includes("home office") ||
normalized.includes("teletrabalho") ||
normalized.includes("anywhere") ||
normalized.includes("worldwide")
) {
return "remoto";
}
if (
normalized.includes("presencial") ||
normalized.includes("onsite") ||
normalized.includes("on site") ||
normalized.includes("on-site") ||
normalized.includes("in office") ||
normalized.includes("escritorio")
) {
return "presencial";
}

return "presencial";
}

function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] {
const level = normalizeComparable(firstQueryValue(query.level));
const location = normalizeComparable(firstQueryValue(query.location));
const type = normalizeComparable(firstQueryValue(query.type));

if (!level && !location && !type) return jobs;

return jobs.filter((job) => {
const candidate = job as SearchJob;
const title = candidate.title ?? "";
const jobLocation = candidate.location ?? "";
const normalizedLocation = normalizeComparable(jobLocation);

const matchesLevel = !level || inferJobLevel(title) === level;
const matchesLocation =
!location || normalizedLocation.includes(location);
const matchesType = !type || inferJobType(candidate) === type;

return matchesLevel && matchesLocation && matchesType;
});
}

function hasPostFetchFilters(query: Request["query"]): boolean {
return Boolean(
firstQueryValue(query.level) ||
firstQueryValue(query.location) ||
firstQueryValue(query.type),
);
}

/**
* @swagger
* /api/jobs/search:
Expand Down Expand Up @@ -42,8 +141,25 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => {
ids = await cacheAbsoluteSMembers("scraper:jobs:index");
}

const { data: pageIds, pagination: meta } = paginate(ids, pagination);
const jobs = await cacheGetJobsByIds(pageIds);
if (!hasPostFetchFilters(req.query)) {
const { data: pageIds, pagination: meta } = paginate(ids, pagination);
const jobs = await cacheGetJobsByIds(pageIds);

return res.json({
total: meta.total,
page: meta.page,
limit: meta.limit,
totalPages: meta.totalPages,
hasNext: meta.hasNext,
hasPrev: meta.hasPrev,
jobs,
source,
});
}

const allJobs = await cacheGetJobsByIds(ids);
const filteredJobs = filterJobs(allJobs, req.query);
const { data: jobs, pagination: meta } = paginate(filteredJobs, pagination);

return res.json({
total: meta.total,
Expand Down
128 changes: 128 additions & 0 deletions backend/tests/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,69 @@ describe("jobsApiApp", () => {
expect(res.body.source).toBe("valkey_global_index");
});

it("GET /jobs/search aplica filtros de level, location e type", async () => {
mocks.cacheGetJobsByIds.mockResolvedValue([
{
id: "id-1",
title: "Desenvolvedor Node.js Júnior",
company: "ACME",
location: "Brasil - Remoto",
},
{
id: "id-2",
title: "Desenvolvedor Node.js Sênior",
company: "Globo",
location: "Estados Unidos - Remoto",
},
{
id: "id-3",
title: "Desenvolvedor Node.js Pleno",
company: "Globex",
location: "Brasil - Hybrid Remote",
},
]);

const app = createJobsApiApp();
const res = await request(app)
.get("/jobs/search")
.query({
keywords: "Node.js",
level: "Júnior",
location: "Brasil",
type: "Remoto",
})
.expect(200);

expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-1" })]);
expect(res.body.total).toBe(1);
});

it("GET /jobs/search diferencia modelo híbrido de remoto", async () => {
mocks.cacheGetJobsByIds.mockResolvedValue([
{
id: "id-1",
title: "Desenvolvedor Node.js",
company: "ACME",
location: "Brasil - Remote",
},
{
id: "id-2",
title: "Desenvolvedor Node.js",
company: "Globex",
location: "Brasil - Hybrid Remote",
},
]);

const app = createJobsApiApp();
const res = await request(app)
.get("/jobs/search")
.query({ keywords: "Node.js", type: "Híbrido" })
.expect(200);

expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-2" })]);
expect(res.body.total).toBe(1);
});

it("GET /jobs/search retorna paginação correta", async () => {
mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 });
mocks.paginate.mockReturnValue({
Expand Down Expand Up @@ -288,6 +351,71 @@ describe("jobsApiApp", () => {
expect(res.body.message).toBe("Erro ao recuperar vagas em memória.");
});

it("GET /jobs/search trata filtros em array e aplica o primeiro valor", async () => {
mocks.cacheGetJobsByIds.mockResolvedValue([
{
id: "id-array",
title: "Desenvolvedor Node.js Júnior",
company: "ACME",
location: "Brasil - Remoto",
},
]);

const app = createJobsApiApp();
const res = await request(app)
.get("/jobs/search")
.query({
keywords: "Node.js",
level: ["Júnior", "Sênior"],
location: ["Brasil", "Portugal"],
type: ["Remoto", "Híbrido"],
})
.expect(200);

expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-array" })]);
expect(res.body.total).toBe(1);
});

it("GET /jobs/search identifica vaga sênior presencial", async () => {
mocks.cacheGetJobsByIds.mockResolvedValue([
{
id: "id-senior",
title: "Tech Lead Sênior",
company: "ACME",
location: "São Paulo - Onsite",
},
]);

const app = createJobsApiApp();
const res = await request(app)
.get("/jobs/search")
.query({ keywords: "Tech Lead", type: "Presencial" })
.expect(200);

expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-senior" })]);
expect(res.body.total).toBe(1);
});

it("GET /jobs/search usa o fallback de tipo presencial quando não há pistas de remoto", async () => {
mocks.cacheGetJobsByIds.mockResolvedValue([
{
id: "id-fallback",
title: "Backend Developer",
company: "ACME",
location: "Brasil",
},
]);

const app = createJobsApiApp();
const res = await request(app)
.get("/jobs/search")
.query({ keywords: "Backend", type: "Presencial" })
.expect(200);

expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-fallback" })]);
expect(res.body.total).toBe(1);
});

// ── keywords ──────────────────────────────────────────────────────────

it("GET /keywords retorna keywords do banco", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import { CandidateLogo } from "../../../../lib/ui/CandidateLogo";
import { useAuth } from "../../../../modules/auth/hooks/useAuth";
import { CandidateLogo } from "../components/CandidateLogo";
import { SidebarFooter } from "./components/SidebarFooter";
import { SidebarItem } from "./components/SidebarItem";
import { NAV_ITEMS } from "./sidebar.config";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,3 @@
// interface CandidateLogoProps {
// size?: "sm" | "md";
// }

// const SIZE_STYLES = {
// sm: "text-xl",
// md: "text-2xl",
// };

// export function CandidateLogo({ size = "md" }: CandidateLogoProps) {
// return (
// <span
// aria-label="Cand!Date!"
// className={`inline-flex items-baseline whitespace-nowrap font-extrabold tracking-normal ${SIZE_STYLES[size]}`}
// >
// <span className="text-[#2f6fff] dark:text-[##2D5CAB]">&lt;</span>
// <span className="text-slate-950 dark:text-slate-100">Cand!Date</span>
// <span className="text-[#2f6fff] dark:text-[#2D5CAB]">!&gt;</span>
// </span>
// );
// }
interface CandidateLogoProps {
size?: "sm" | "md";
}
Expand Down
2 changes: 1 addition & 1 deletion front_admin/src/modules/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Lock } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CandidateLogo } from "../../app/layouts/MainLayout/components/CandidateLogo";
import { ErrorMessage } from "../../components/common/ErrorMessage";
import { CandidateLogo } from "../../lib/ui/CandidateLogo";
import { BrandPanel } from "./components/BrandPanel";
import { LoginForm } from "./components/LoginForm";
import { LoginHeader } from "./components/LoginHeader";
Expand Down
2 changes: 1 addition & 1 deletion front_admin/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src", "../shared/CandidateLogo.tsx"]
}
1 change: 1 addition & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
tsconfigRootDir: import.meta.dirname,
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
Expand Down
Loading
Loading