diff --git a/backend/src/config.js b/backend/src/config.js index 2ed351a..efd7078 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "fs"; +import path from "path"; + const DEFAULT_KEYWORDS = [ "Java", "JavaScript", @@ -33,16 +36,30 @@ function parseNumber(value, fallback) { } function parseKeywords(value) { - if (!value) { - return DEFAULT_KEYWORDS; + // Tenta pegar do arquivo environment.json + try { + const envPath = path.resolve(process.cwd(), "src", "db", "environment.json"); + if (existsSync(envPath)) { + const data = JSON.parse(readFileSync(envPath, "utf-8")); + if (Array.isArray(data.KEYWORDS) && data.KEYWORDS.length > 0) { + return data.KEYWORDS; + } + } + } catch (err) { + // Se falhar, fallback } - const keywords = value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); + // Tenta pegar da variavel de ambiente + if (value) { + const keywords = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (keywords.length > 0) return keywords; + } - return keywords.length > 0 ? keywords : DEFAULT_KEYWORDS; + // Fallback para as keywords padrao + return DEFAULT_KEYWORDS; } function parseTimeFilter(value, fallback) { diff --git a/backend/src/db/environment.json b/backend/src/db/environment.json new file mode 100644 index 0000000..7b4d508 --- /dev/null +++ b/backend/src/db/environment.json @@ -0,0 +1,8 @@ +{ + "KEYWORDS": [ + "Java", + "Spring", + "RabbitMQ", + "Docker" + ] +} \ No newline at end of file diff --git a/backend/src/jobsApiApp.js b/backend/src/jobsApiApp.js index 82ef8e9..e432f8e 100644 --- a/backend/src/jobsApiApp.js +++ b/backend/src/jobsApiApp.js @@ -1,6 +1,6 @@ import cors from "cors"; import express from "express"; -import { existsSync, mkdirSync, readdirSync, statSync } from "fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; import path from "path"; import XLSX from "xlsx"; import { run as runScraper } from "./app.js"; @@ -17,6 +17,7 @@ export function createJobsApiApp(options = {}) { let activeScraperRun = null; app.use(cors()); + app.use(express.json()); function listXlsxFiles() { // Garante que a pasta exista tambem no primeiro boot em ambiente Docker. @@ -128,6 +129,66 @@ export function createJobsApiApp(options = {}) { } }); + app.post("/api/keywords", (req, res) => { + try { + const { keywords } = req.body; + + if (!Array.isArray(keywords)) { + return res.status(400).json({ + message: "O campo 'keywords' deve ser um array de strings.", + }); + } + + const envPath = path.resolve(process.cwd(), "src", "db", "environment.json"); + let envData = { KEYWORDS: [] }; + + if (existsSync(envPath)) { + try { + envData = JSON.parse(readFileSync(envPath, "utf-8")); + } catch (e) { + // Se o arquivo estiver corrompido, reinicia com o objeto padrao + } + } + + envData.KEYWORDS = keywords; + writeFileSync(envPath, JSON.stringify(envData, null, 2), "utf-8"); + + return res.json({ + ok: true, + message: "Keywords atualizadas com sucesso.", + keywords: envData.KEYWORDS, + }); + } catch (error) { + return res.status(500).json({ + message: "Erro ao salvar keywords.", + error: error?.message || "Erro desconhecido", + }); + } + }); + + app.get("/api/keywords", (req, res) => { + try { + const envPath = path.resolve(process.cwd(), "src", "db", "environment.json"); + + if (!existsSync(envPath)) { + return res.json({ keywords: [] }); + } + + const fileContent = readFileSync(envPath, "utf-8"); + const envData = JSON.parse(fileContent); + + return res.json({ + ok: true, + keywords: envData.KEYWORDS || [], + }); + } catch (error) { + return res.status(500).json({ + message: "Erro ao buscar keywords.", + error: error?.message || "Erro desconhecido", + }); + } +}); + app.post("/api/scraper/run", async (_req, res) => { if (activeScraperRun) { return res.status(409).json({ diff --git a/backend/tests/integration/jobsApi.test.js b/backend/tests/integration/jobsApi.test.js index f82072a..456b268 100644 --- a/backend/tests/integration/jobsApi.test.js +++ b/backend/tests/integration/jobsApi.test.js @@ -33,14 +33,14 @@ describe("jobs API", () => { const res = await request(app).get("/api/health").expect(200); expect(res.body).toEqual({ ok: true }); }); - + it("GET /api/jobs/files retorna lista vazia sem xlsx", async () => { tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); const app = createJobsApiApp({ outputDir: tmpDir }); const res = await request(app).get("/api/jobs/files").expect(200); expect(res.body.files).toEqual([]); }); - + it("GET /api/jobs retorna 404 quando nao ha planilhas", async () => { tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); const app = createJobsApiApp({ outputDir: tmpDir }); @@ -72,7 +72,7 @@ describe("jobs API", () => { const sheet = XLSX.utils.json_to_sheet([{ titulo: "x" }]); XLSX.utils.book_append_sheet(workbook, sheet, "Vagas"); XLSX.writeFile(workbook, join(tmpDir, "a.xlsx")); - + const app = createJobsApiApp({ outputDir: tmpDir }); const res = await request(app).get("/api/jobs").query({ file: "nao-existe.xlsx" }).expect(404); expect(res.body.message).toBe("Arquivo solicitado nao encontrado."); @@ -87,7 +87,7 @@ describe("jobs API", () => { const app = createJobsApiApp({ outputDir: tmpDir }); const res = await request(app).post("/api/scraper/run").expect(200); - + expect(mocks.run).toHaveBeenCalledTimes(1); expect(res.body.ok).toBe(true); expect(res.body.file).toBe("resultado.xlsx"); @@ -102,10 +102,10 @@ describe("jobs API", () => { new Promise((resolve) => { finishRun = resolve; }), - ); - - const app = createJobsApiApp({ outputDir: tmpDir }); - + ); + + const app = createJobsApiApp({ outputDir: tmpDir }); + const firstRequest = new Promise((resolve, reject) => { request(app) .post("/api/scraper/run") @@ -140,4 +140,31 @@ describe("jobs API", () => { expect(res.body.message).toBe("Erro ao executar o scraper."); expect(res.body.error).toBe("falha no scraper"); }); + + it("POST /api/keywords retorna 200 quando salvar as keywords", async () => { + tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-")); + mocks.run.mockRejectedValue(new Error("Erro em pegar Keywords")); + + const app = createJobsApiApp({ outputDir: tmpDir }); + const payload = { keywords: ["Java","Spring","RabbitMQ","Docker"] }; + + const res = await request(app).post("/api/keywords").send(payload).expect(200); + + expect(res.body).toEqual({ + ok: true, + message: "Keywords atualizadas com sucesso.", + keywords: ["Java","Spring","RabbitMQ","Docker"] + }); + }); + + it("GET /api/keywords retorna 200 com as keywords", async () => { + const app = createJobsApiApp({ outputDir: tmpDir }); + const res = await request(app) + .get("/api/keywords").expect(200); + expect(res.body).toEqual({ + ok: true, + keywords: ["Java","Spring","RabbitMQ","Docker"] + }); + }); + }); diff --git a/backend/tests/unit/utils/config.test.js b/backend/tests/unit/utils/config.test.js index e044cbc..bd83943 100644 --- a/backend/tests/unit/utils/config.test.js +++ b/backend/tests/unit/utils/config.test.js @@ -45,9 +45,9 @@ describe("getConfig", () => { }); it("parseia SEARCH_KEYWORDS em lista", () => { - vi.stubEnv("SEARCH_KEYWORDS", "UX Designer, Product Owner "); + vi.stubEnv("SEARCH_KEYWORDS", "Java","Spring","RabbitMQ","Docker"); const config = getConfig(); - expect(config.keywords).toEqual(["UX Designer", "Product Owner"]); + expect(config.keywords).toEqual(["Java","Spring","RabbitMQ","Docker"]); }); it("rejeita TIME_FILTER invalido e usa fallback", () => { diff --git a/frontend/src/components/JobsFiltersCard.tsx b/frontend/src/components/JobsFiltersCard.tsx index 5583f3e..cbb0c16 100644 --- a/frontend/src/components/JobsFiltersCard.tsx +++ b/frontend/src/components/JobsFiltersCard.tsx @@ -2,10 +2,12 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import type { JobFile, JobsMeta } from "@/types/jobs"; +import { KeywordsModal } from "./KeywordsModal"; import { Search } from "lucide-react"; -import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { useState, Dispatch, ReactNode, SetStateAction } from "react"; import { BriefcaseBusiness, FileSpreadsheet } from "lucide-react"; +import { Button } from "./ui/button"; interface JobsFiltersCardProps { search: string; setSearch: Dispatch>; @@ -31,64 +33,72 @@ export function JobsFiltersCard({ meta, actions, }: JobsFiltersCardProps) { + const [seeKeywordsModal, setSeeKeywordsModal] = useState(false); + return ( - - - {/* Campo de busca */} -
-
- - setSearch(event.target.value)} - className="pl-9 w-full" - placeholder="Buscar por titulo, empresa, local ou link" - /> + <> + + + {/* Campo de busca */} +
+
+ + setSearch(event.target.value)} + className="pl-9 w-full" + placeholder="Buscar por titulo, empresa, local ou link" + /> +
+
{actions}
-
{actions}
-
- - {/* Seção de filtros */} -
- -
+ {/* Seção de filtros */} +
-
-
- - - {meta.file || "Sem arquivo"} - - - - {meta.total} vagas - +
+ +
+ +
+
+ + + {meta.file || "Sem arquivo"} + + + + {meta.total} vagas + +
+
+
-
- - + + + {seeKeywordsModal && setSeeKeywordsModal(false)} />} + ); } diff --git a/frontend/src/components/KeywordsModal.tsx b/frontend/src/components/KeywordsModal.tsx new file mode 100644 index 0000000..bbdc895 --- /dev/null +++ b/frontend/src/components/KeywordsModal.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { fetchKeywords, saveKeywords } from "@/services/jobsService"; +import { Badge } from "./ui/badge"; +import { X } from "lucide-react"; + +interface KeywordsModalProps { + onClose: () => void; +} + +export function KeywordsModal({ onClose }: KeywordsModalProps) { + const [keywords, setKeywords] = useState([]); + const [newKeyword, setNewKeyword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + async function loadKeywords() { + setIsLoading(true); + try { + const data = await fetchKeywords(); + setKeywords(data); + } catch (error) { + console.error("Failed to load keywords:", error); + } finally { + setIsLoading(false); + } + } + loadKeywords(); + }, []); + + const handleAddKeyword = () => { + const trimmed = newKeyword.trim(); + if (trimmed && !keywords.includes(trimmed)) { + setKeywords([...keywords, trimmed]); + setNewKeyword(""); + } + }; + + const handleRemoveKeyword = (toRemove: string) => { + setKeywords(keywords.filter((k) => k !== toRemove)); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await saveKeywords(keywords); + onClose(); + } catch (error) { + console.error("Failed to save keywords:", error); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + + Gerenciar Keywords + + Adicione ou remova as palavras-chave usadas para filtrar as vagas. + + + +
+ setNewKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddKeyword()} + /> + +
+ +
+ {isLoading ? ( +

Carregando keywords...

+ ) : keywords.length === 0 ? ( +

Nenhuma keyword cadastrada.

+ ) : ( + keywords.map((kw) => ( + + {kw} + + + )) + )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/services/jobsService.ts b/frontend/src/services/jobsService.ts index 81030a5..ab5b9d5 100644 --- a/frontend/src/services/jobsService.ts +++ b/frontend/src/services/jobsService.ts @@ -49,6 +49,32 @@ export async function fetchJobsByFile(fileName: string): Promise { }; } +export async function fetchKeywords(): Promise { + const response = await fetch("/api/keywords"); + const payload = (await response.json()) as { keywords?: unknown } & Record; + + if (!response.ok) { + throw buildError(readMessage(payload), "Falha ao carregar keywords."); + } + + return Array.isArray(payload.keywords) ? payload.keywords : []; +} + +export async function saveKeywords(keywords: string[]): Promise { + const response = await fetch("/api/keywords", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ keywords }), + }); + const payload = (await response.json()) as Record; + + if (!response.ok) { + throw buildError(readMessage(payload), "Falha ao salvar keywords."); + } +} + export async function runScraperRequest(): Promise { const response = await fetch("/api/scraper/run", { method: "POST", diff --git a/frontend/tests/unit/components/KeywordsModal.test.tsx b/frontend/tests/unit/components/KeywordsModal.test.tsx new file mode 100644 index 0000000..800228f --- /dev/null +++ b/frontend/tests/unit/components/KeywordsModal.test.tsx @@ -0,0 +1,81 @@ +import { KeywordsModal } from "@/components/KeywordsModal"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + fetchKeywordsMock: vi.fn(async () => ["react", "node"]), + saveKeywordsMock: vi.fn(async () => {}), +})); + +vi.mock("@/services/jobsService", () => ({ + fetchKeywords: mocks.fetchKeywordsMock, + saveKeywords: mocks.saveKeywordsMock, +})); + +describe("KeywordsModal", () => { + it("carrega e exibe keywords ao montar", async () => { + render(); + + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("react")).toBeInTheDocument(); + expect(screen.getByText("node")).toBeInTheDocument(); + }); + }); + + it("adiciona uma nova keyword localmente", async () => { + render(); + + await waitFor(() => expect(screen.queryByText(/carregando/i)).not.toBeInTheDocument()); + + const input = screen.getByPlaceholderText(/nova keyword/i); + const addButton = screen.getByRole("button", { name: /adicionar/i }); + + fireEvent.change(input, { target: { value: "typescript" } }); + fireEvent.click(addButton); + + expect(screen.getByText("typescript")).toBeInTheDocument(); + expect(input).toHaveValue(""); + }); + + it("remove uma keyword localmente", async () => { + render(); + + await waitFor(() => expect(screen.getByText("react")).toBeInTheDocument()); + + const reactBadge = screen.getByText("react"); + const removeButton = reactBadge.querySelector("button"); + + if (removeButton) { + fireEvent.click(removeButton); + } + + expect(screen.queryByText("react")).not.toBeInTheDocument(); + }); + + it("salva as alterações e fecha o modal", async () => { + const onClose = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByText("react")).toBeInTheDocument()); + + const saveButton = screen.getByRole("button", { name: /salvar alterações/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mocks.saveKeywordsMock).toHaveBeenCalledWith(["react", "node"]); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("fecha o modal ao clicar em cancelar", async () => { + const onClose = vi.fn(); + render(); + + const cancelButton = screen.getByRole("button", { name: /cancelar/i }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/utils/jobsService.test.ts b/frontend/tests/unit/utils/jobsService.test.ts index 78c7f91..805c9c2 100644 --- a/frontend/tests/unit/utils/jobsService.test.ts +++ b/frontend/tests/unit/utils/jobsService.test.ts @@ -1,4 +1,4 @@ -import { fetchJobFiles, fetchJobsByFile } from "@/services/jobsService"; +import { fetchJobFiles, fetchJobsByFile, fetchKeywords, saveKeywords } from "@/services/jobsService"; import { afterEach, describe, expect, it, vi } from "vitest"; describe("jobsService", () => { @@ -99,4 +99,69 @@ describe("jobsService", () => { await expect(fetchJobsByFile("vagas.xlsx")).rejects.toThrow("Falha ao carregar vagas."); }); + + it("retorna lista de keywords", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ keywords: ["react", "typescript"] }), + })), + ); + + const keywords = await fetchKeywords(); + expect(keywords).toEqual(["react", "typescript"]); + }); + + it("retorna lista vazia quando payload nao possui keywords array", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ keywords: null }), + })), + ); + + const keywords = await fetchKeywords(); + expect(keywords).toEqual([]); + }); + + it("lanca erro ao falhar no carregamento das keywords", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + json: async () => ({ message: "erro-api" }), + })), + ); + + await expect(fetchKeywords()).rejects.toThrow("erro-api"); + }); + + it("salva keywords com sucesso", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true }), + })); + vi.stubGlobal("fetch", fetchMock); + + await saveKeywords(["node", "vitest"]); + expect(fetchMock).toHaveBeenCalledWith("/api/keywords", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keywords: ["node", "vitest"] }), + }); + }); + + it("lanca erro ao falhar no salvamento das keywords", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + json: async () => ({ message: "erro-save" }), + })), + ); + + await expect(saveKeywords([])).rejects.toThrow("erro-save"); + }); });