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/
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,66 @@ npm run dev:backend
- `npm run test`: testes com Vitest
- `npm run test:coverage`: coverage com Vitest

## Aplicativo Desktop (Electron)

O projeto tambem pode ser executado como app desktop Windows, sem precisar subir Docker no computador do usuario final.

### Como o app funciona

- O Electron inicia o backend internamente na porta `3001`
- A UI React e carregada dentro da janela desktop
- O botao **Buscar vagas** dispara o scraper via API (`POST /api/scraper/run`)
- Os arquivos de saida (XLSX/PDF) sao salvos no diretorio de dados do usuario

### Estrutura principal

```text
electron/
├─ main.js
├─ preload.js
└─ loading.html
```

### Rodar em modo desktop (desenvolvimento)

1. Instale dependencias na raiz:

```bash
npm install
```

2. Gere o build do frontend:

```bash
npm run build:frontend
```

3. Inicie o app Electron:

```bash
npm run electron
```

Opcional (atalho para build + electron):

```bash
npm run electron:dev
```

### Gerar instalador .exe para distribuir

Execute na raiz do monorepo:

```bash
npm run dist
```

O instalador sera gerado em `dist-electron/` (target `nsis`).

### Observacao para Windows (build local)

Se o `electron-builder` falhar com erro de symlink (winCodeSign), habilite o **Modo de Desenvolvedor** no Windows ou rode o terminal como Administrador antes de executar `npm run dist`.

## Testes e cobertura

Estrutura de testes:
Expand Down
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());

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();
Comment on lines +111 to +115

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.

runScraper() usa getConfig() (que lê OUTPUT_FILE/PDF_FILE do process.env) para decidir onde gravar os arquivos, mas este endpoint lista/retorna arquivos a partir de outputDir (opção do createJobsApiApp). Se outputDir for customizado (ex.: em testes/uso embutido), o scraper pode escrever em outro diretório e a resposta pode não refletir o resultado do run. Considere alinhar o diretório de output do scraper com outputDir (passando config para o scraper ou setando env defaults com base em outputDir).

Copilot uses AI. Check for mistakes.
return res.json({
ok: true,
file: files[0]?.file ?? null,
modifiedAt: files[0]?.modifiedAt ?? null,
totalFiles: files.length,
});
} 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." });

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 para rotas /api/* está retornando { error: ... }, mas o frontend (e os demais endpoints) usam o campo message para exibir erros. Para manter consistência e garantir que readMessage(payload) funcione, troque para { message: "Rota não encontrada." } (e ajuste o teste correspondente).

Suggested change
return res.status(404).json({ error: "Rota não encontrada." });
return res.status(404).json({ message: "Rota não encontrada." });

Copilot uses AI. Check for mistakes.
}
res.sendFile(path.join(staticDir, "index.html"));
});
}

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." });

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.

Este teste valida o fallback SPA com { error: "Rota não encontrada." }, mas a API e o frontend padronizam mensagens de erro no campo message. Após ajustar o middleware em backend/src/server.js para usar message, atualize esta asserção para refletir o payload correto.

Suggested change
expect(apiRes.json).toHaveBeenCalledWith({ error: "Rota não encontrada." });
expect(apiRes.json).toHaveBeenCalledWith({ message: "Rota não encontrada." });

Copilot uses AI. Check for mistakes.
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();
});
});
Loading
Loading