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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ coverage/
frontend/coverage/
backend/coverage/
*.lcov
.nyc_output/
.nyc_output/

# Dist-electron
dist-electron/
30 changes: 30 additions & 0 deletions backend/src/jobsApiApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import express from "express";
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
import path from "path";
import XLSX from "xlsx";
import { run as runScraper } from "./app.js";

/**
* @param {{ outputDir?: string }} [options]
*/
export function createJobsApiApp(options = {}) {
const outputDir = options.outputDir ?? path.resolve(process.cwd(), "output");
const app = express();
let activeScraperRun = null;

app.use(cors());

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.use(cors()) com configuração padrão permite qualquer origem. Com a adição de POST /api/scraper/run, isso facilita que qualquer site aberto no navegador do usuário acione o scraper se o servidor estiver acessível. Sugestão: configurar CORS de forma restritiva (por exemplo, condicionar por env no modo Electron e permitir apenas a origem esperada) e/ou adicionar algum mecanismo simples de autenticação/nonce para endpoints de execução.

Copilot uses AI. Check for mistakes.

Expand Down Expand Up @@ -99,5 +101,33 @@ export function createJobsApiApp(options = {}) {
}
});

app.post("/api/scraper/run", async (_req, res) => {
if (activeScraperRun) {
return res.status(409).json({
message: "O scraper ja esta em execucao.",
});
}

try {
activeScraperRun = runScraper();
await activeScraperRun;

const files = listXlsxFiles();
return res.json({
ok: true,
file: files[0]?.file ?? null,
modifiedAt: files[0]?.modifiedAt ?? null,
totalFiles: files.length,
});
Comment on lines +104 to +121

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O novo endpoint POST /api/scraper/run não tem cobertura de testes, enquanto os demais endpoints de jobs já possuem testes de integração. Recomenda-se adicionar testes que: (1) validem o 409 quando já existe uma execução ativa e (2) validem o 200/500 sem rodar o scraper real (mockando run()), para evitar flakiness e tempo alto de execução.

Copilot uses AI. Check for mistakes.
} catch (error) {
return res.status(500).json({
message: "Erro ao executar o scraper.",
error: error?.message || "Erro desconhecido",
});
} finally {
activeScraperRun = null;
}
});

return app;
}
30 changes: 27 additions & 3 deletions backend/src/server.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import "dotenv/config";
import express from "express";
import { existsSync } from "fs";
import path from "path";
import { createJobsApiApp } from "./jobsApiApp.js";

const PORT = Number(process.env.PORT || 3001);
const app = createJobsApiApp({
outputDir: path.resolve(process.cwd(), "output"),
});

// When running inside Electron, main.js sets ELECTRON_OUTPUT_DIR to a
// writable user-data path. Fall back to the CWD-relative folder for dev/CLI.
const outputDir = process.env.ELECTRON_OUTPUT_DIR
? process.env.ELECTRON_OUTPUT_DIR
: path.resolve(process.cwd(), "output");

const app = createJobsApiApp({ outputDir });

// ── Electron static-file serving ───────────────────────────────────────────
// When ELECTRON_STATIC_DIR is set, serve the React production build so that
// the Electron window can load everything from http://localhost:<PORT>.
const staticDir = process.env.ELECTRON_STATIC_DIR;
if (staticDir && existsSync(staticDir)) {
app.use(express.static(staticDir));

// SPA catch-all: return index.html for any non-API route so that React
// Router (if used in future) works correctly.
app.use((req, res) => {
if (req.path.startsWith("/api")) {
return res.status(404).json({ error: "Rota não encontrada." });
}
res.sendFile(path.join(staticDir, "index.html"));
});
Comment on lines +26 to +31

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O fallback de SPA foi registrado com app.use(...), então ele intercepta qualquer método HTTP (POST/PUT/etc.) e pode acabar devolvendo index.html para rotas não-API. Além disso, para rotas /api/* ele retorna { error: ... }, mas o frontend lê mensagens em payload.message. Sugestão: limitar o catch-all a GET/HEAD e padronizar o campo de erro para message (mantendo o formato das demais respostas da API).

Suggested change
app.use((req, res) => {
if (req.path.startsWith("/api")) {
return res.status(404).json({ error: "Rota não encontrada." });
}
res.sendFile(path.join(staticDir, "index.html"));
});
app.get("*", (req, res) => {
if (req.path.startsWith("/api")) {
return res.status(404).json({ message: "Rota não encontrada." });
}
res.sendFile(path.join(staticDir, "index.html"));
});
app.head("*", (req, res) => {
if (req.path.startsWith("/api")) {
return res.status(404).json({ message: "Rota não encontrada." });
}
res.sendFile(path.join(staticDir, "index.html"));
});

Copilot uses AI. Check for mistakes.
}

app.listen(PORT, () => {
// eslint-disable-next-line no-console
Expand Down
79 changes: 78 additions & 1 deletion backend/tests/integration/jobsApi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@ import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import request from "supertest";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import XLSX from "xlsx";

const mocks = vi.hoisted(() => ({
run: vi.fn(),
}));

vi.mock("../../src/app.js", () => ({
run: mocks.run,
}));

import { createJobsApiApp } from "../../src/jobsApiApp.js";

describe("jobs API", () => {
let tmpDir;

beforeEach(() => {
vi.clearAllMocks();
mocks.run.mockResolvedValue(undefined);
});

afterEach(() => {
tmpDir = undefined;
});
Expand Down Expand Up @@ -63,4 +77,67 @@ describe("jobs API", () => {
const res = await request(app).get("/api/jobs").query({ file: "nao-existe.xlsx" }).expect(404);
expect(res.body.message).toBe("Arquivo solicitado nao encontrado.");
});

it("POST /api/scraper/run executa scraper e retorna metadados", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.json_to_sheet([{ titulo: "x" }]);
XLSX.utils.book_append_sheet(workbook, sheet, "Vagas");
XLSX.writeFile(workbook, join(tmpDir, "resultado.xlsx"));

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");
expect(res.body.totalFiles).toBe(1);
});

it("POST /api/scraper/run retorna 409 quando ja existe execucao ativa", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
let finishRun;
mocks.run.mockImplementation(
() =>
new Promise((resolve) => {
finishRun = resolve;
}),
);

const app = createJobsApiApp({ outputDir: tmpDir });

const firstRequest = new Promise((resolve, reject) => {
request(app)
.post("/api/scraper/run")
.end((error, response) => {
if (error) {
reject(error);
return;
}
resolve(response);
});
});

await vi.waitFor(() => {
expect(mocks.run).toHaveBeenCalledTimes(1);
});

const conflict = await request(app).post("/api/scraper/run").expect(409);
expect(conflict.body.message).toBe("O scraper ja esta em execucao.");

finishRun();
const firstResult = await firstRequest;
expect(firstResult.status).toBe(200);
});

it("POST /api/scraper/run retorna 500 quando scraper falha", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
mocks.run.mockRejectedValue(new Error("falha no scraper"));

const app = createJobsApiApp({ outputDir: tmpDir });
const res = await request(app).post("/api/scraper/run").expect(500);

expect(res.body.message).toBe("Erro ao executar o scraper.");
expect(res.body.error).toBe("falha no scraper");
});
});
63 changes: 63 additions & 0 deletions backend/tests/unit/services/server.test.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
import path from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
listen: vi.fn(),
use: vi.fn(),
createJobsApiApp: vi.fn(),
consoleLog: vi.fn(),
existsSync: vi.fn(),
staticMiddleware: vi.fn(),
expressStatic: vi.fn(),
}));

mocks.createJobsApiApp.mockReturnValue({
listen: mocks.listen,
use: mocks.use,
});

mocks.expressStatic.mockReturnValue(mocks.staticMiddleware);

vi.mock("../../../src/jobsApiApp.js", () => ({
createJobsApiApp: mocks.createJobsApiApp,
}));

vi.mock("fs", () => ({
existsSync: mocks.existsSync,
}));

vi.mock("express", () => ({
default: {
static: mocks.expressStatic,
},
}));

describe("server entry", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.env.PORT = "3100";
delete process.env.ELECTRON_STATIC_DIR;
delete process.env.ELECTRON_OUTPUT_DIR;
mocks.existsSync.mockReturnValue(false);
vi.spyOn(console, "log").mockImplementation(mocks.consoleLog);
});

Expand All @@ -41,4 +62,46 @@ describe("server entry", () => {

expect(mocks.listen).toHaveBeenCalledWith(3001, expect.any(Function));
});

it("usa ELECTRON_OUTPUT_DIR quando definido", async () => {
process.env.ELECTRON_OUTPUT_DIR = "/tmp/electron-output";

await import("../../../src/server.js");

expect(mocks.createJobsApiApp).toHaveBeenCalledWith({
outputDir: "/tmp/electron-output",
});
});

it("registra static e fallback SPA quando ELECTRON_STATIC_DIR existe", async () => {
process.env.ELECTRON_STATIC_DIR = "/tmp/frontend-dist";
mocks.existsSync.mockReturnValue(true);

await import("../../../src/server.js");

expect(mocks.expressStatic).toHaveBeenCalledWith("/tmp/frontend-dist");
expect(mocks.use).toHaveBeenCalledTimes(2);
expect(mocks.use).toHaveBeenNthCalledWith(1, mocks.staticMiddleware);

const fallbackHandler = mocks.use.mock.calls[1][0];

const apiRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
sendFile: vi.fn(),
};
fallbackHandler({ path: "/api/unknown" }, apiRes);
expect(apiRes.status).toHaveBeenCalledWith(404);
expect(apiRes.json).toHaveBeenCalledWith({ error: "Rota não encontrada." });
expect(apiRes.sendFile).not.toHaveBeenCalled();

const pageRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
sendFile: vi.fn(),
};
fallbackHandler({ path: "/dashboard" }, pageRes);
expect(pageRes.sendFile).toHaveBeenCalledWith(path.join("/tmp/frontend-dist", "index.html"));
expect(pageRes.status).not.toHaveBeenCalled();
});
});
81 changes: 81 additions & 0 deletions electron/loading.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<!--
Strict CSP: no inline scripts, no external resources.
All styling is inline CSS only (allowed by 'unsafe-inline' for style-src).
-->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'" />
<title>Vagas Full – Carregando</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #0f172a; /* slate-900 */
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #f1f5f9; /* slate-100 */
user-select: none;
-webkit-app-region: drag; /* makes frameless window draggable */
}

.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 48px;
background: #1e293b; /* slate-800 */
border: 1px solid #334155; /* slate-700 */
border-radius: 16px;
width: 420px;
text-align: center;
}

.logo {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
color: #38bdf8; /* sky-400 */
}

.spinner {
width: 44px;
height: 44px;
border: 4px solid #334155;
border-top-color: #38bdf8; /* sky-400 */
border-radius: 50%;
animation: spin 0.85s linear infinite;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

.status {
font-size: 16px;
font-weight: 600;
color: #e2e8f0; /* slate-200 */
}

.hint {
font-size: 13px;
color: #64748b; /* slate-500 */
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<div class="logo">Vagas Full</div>
<div class="spinner"></div>
<p class="status">Buscando vagas no LinkedIn...</p>
<p class="hint">Isso pode levar alguns instantes.<br>Aguarde enquanto os dados são coletados.</p>
</div>
</body>
</html>
Loading
Loading