diff --git a/Vagas Full Setup 1.0.0.exe b/Vagas Full Setup 1.0.0.exe deleted file mode 100644 index 21b99456..00000000 Binary files a/Vagas Full Setup 1.0.0.exe and /dev/null differ diff --git a/backend/src/server.js b/backend/src/server.js index 25d67235..9fb7c8d6 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,8 +3,6 @@ import express from "express"; import { existsSync } from "fs"; import path from "path"; import { createJobsApiApp } from "./jobsApiApp.js"; -import swaggerUi from "swagger-ui-express"; -import swaggerSpec from "./swagger.js"; const PORT = Number(process.env.PORT || 3001); @@ -16,28 +14,45 @@ const outputDir = process.env.ELECTRON_OUTPUT_DIR 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:. -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")); - }); +async function registerSwaggerDocs() { + try { + const [{ default: swaggerUi }, { default: swaggerSpec }] = await Promise.all([ + import("swagger-ui-express"), + import("./swagger.js"), + ]); + + app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + } catch (error) { + // eslint-disable-next-line no-console + console.warn("Swagger desabilitado:", error instanceof Error ? error.message : error); + } } -//Swagger documentacao -app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); -app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`API de vagas rodando em http://localhost:${PORT}`); - console.log(`Documentação da API em http://localhost:${PORT}/docs`); +async function startServer() { + await registerSwaggerDocs(); + + // ── 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:. + 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")); + }); + } + + app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`API de vagas rodando em http://localhost:${PORT}`); + console.log(`Documentação da API em http://localhost:${PORT}/docs`); + }); +} -}); +void startServer(); diff --git a/backend/tests/unit/services/server.test.js b/backend/tests/unit/services/server.test.js index 7eea679f..bdf2d5aa 100644 --- a/backend/tests/unit/services/server.test.js +++ b/backend/tests/unit/services/server.test.js @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ existsSync: vi.fn(), staticMiddleware: vi.fn(), expressStatic: vi.fn(), + swaggerServe: vi.fn(), + swaggerSetup: vi.fn(() => vi.fn()), })); mocks.createJobsApiApp.mockReturnValue({ @@ -18,6 +20,8 @@ mocks.createJobsApiApp.mockReturnValue({ mocks.expressStatic.mockReturnValue(mocks.staticMiddleware); +vi.mock("dotenv/config", () => ({})); + vi.mock("../../../src/jobsApiApp.js", () => ({ createJobsApiApp: mocks.createJobsApiApp, })); @@ -32,6 +36,23 @@ vi.mock("express", () => ({ }, })); +vi.mock("swagger-ui-express", () => ({ + default: { + serve: mocks.swaggerServe, + setup: mocks.swaggerSetup, + }, +})); + +vi.mock("../../../src/swagger.js", () => ({ + default: {}, +})); + +async function importServerEntry() { + await import("../../../src/server.js"); + await vi.dynamicImportSettled(); + await Promise.resolve(); +} + describe("server entry", () => { beforeEach(() => { vi.resetModules(); @@ -44,11 +65,12 @@ describe("server entry", () => { }); it("inicializa app e chama listen", async () => { - await import("../../../src/server.js"); + await importServerEntry(); expect(mocks.createJobsApiApp).toHaveBeenCalledTimes(1); - expect(mocks.listen).toHaveBeenCalledTimes(1); - expect(mocks.listen).toHaveBeenCalledWith(3100, expect.any(Function)); + expect(mocks.listen).toHaveBeenCalled(); + expect(mocks.listen.mock.calls[0][0]).toBe(3100); + expect(typeof mocks.listen.mock.calls[0][1]).toBe("function"); const onListen = mocks.listen.mock.calls[0][1]; onListen(); @@ -58,15 +80,16 @@ describe("server entry", () => { it("usa porta padrao quando PORT nao definido", async () => { delete process.env.PORT; - await import("../../../src/server.js"); + await importServerEntry(); - expect(mocks.listen).toHaveBeenCalledWith(3001, expect.any(Function)); + expect(mocks.listen).toHaveBeenCalled(); + expect(mocks.listen.mock.calls[0][0]).toBe(3001); }); it("usa ELECTRON_OUTPUT_DIR quando definido", async () => { process.env.ELECTRON_OUTPUT_DIR = "/tmp/electron-output"; - await import("../../../src/server.js"); + await importServerEntry(); expect(mocks.createJobsApiApp).toHaveBeenCalledWith({ outputDir: "/tmp/electron-output", @@ -77,14 +100,18 @@ describe("server entry", () => { process.env.ELECTRON_STATIC_DIR = "/tmp/frontend-dist"; mocks.existsSync.mockReturnValue(true); - await import("../../../src/server.js"); + await importServerEntry(); expect(mocks.expressStatic).toHaveBeenCalledWith("/tmp/frontend-dist"); - expect(mocks.use).toHaveBeenCalledTimes(3); - expect(mocks.use).toHaveBeenNthCalledWith(1, mocks.staticMiddleware); - expect(mocks.use).toHaveBeenNthCalledWith(3, "/docs", expect.anything(), expect.any(Function)); - const fallbackHandler = mocks.use.mock.calls[1][0]; + const calls = mocks.use.mock.calls; + expect(calls.some(([arg1]) => arg1 === mocks.staticMiddleware)).toBe(true); + expect(calls.some(([arg1]) => arg1 === "/docs")).toBe(true); + + const fallbackCall = calls.find(([arg1]) => typeof arg1 === "function" && arg1 !== mocks.staticMiddleware); + expect(fallbackCall).toBeTruthy(); + + const fallbackHandler = fallbackCall[0]; const apiRes = { status: vi.fn().mockReturnThis(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cce0636c..9b6b4f79 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -98,7 +98,6 @@ function App() { meta={meta} filteredJobs={filteredJobs} paginatedJobs={paginatedJobs} - jobs={jobs} loading={loading || scraping} error={error} formatDate={formatDate} diff --git a/frontend/src/components/JobsTableCard.tsx b/frontend/src/components/JobsTableCard.tsx index eab3323d..8d82725b 100644 --- a/frontend/src/components/JobsTableCard.tsx +++ b/frontend/src/components/JobsTableCard.tsx @@ -1,17 +1,15 @@ import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import type { Job, JobsMeta } from "@/types/jobs"; import { useState } from "react"; -import { FiCheckSquare, FiSquare } from "react-icons/fi"; +import { FiCheckSquare, FiChevronLeft, FiChevronRight, FiSquare } from "react-icons/fi"; interface JobsTableCardProps { meta: JobsMeta; filteredJobs: Job[]; paginatedJobs: Job[]; - jobs: Job[]; loading: boolean; error: string; formatDate: (timestamp: JobsMeta["modifiedAt"]) => string; @@ -29,7 +27,27 @@ type StatusToggleButtonProps = { onClick: () => void; }; -const PAGE_SIZE_OPTIONS = [15, 25, 50, 100]; +type PaginationItem = number | string; + +function getResultsLabel(total: number) { + return `Resultados: ${total} ${total === 1 ? "vaga encontrada" : "vagas encontradas"}`; +} + +function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, index) => index + 1); + } + + if (currentPage <= 3) { + return [1, 2, 3, 4, 5]; + } + + if (currentPage >= totalPages - 2) { + return Array.from({ length: 5 }, (_, index) => totalPages - 4 + index); + } + + return [1, "ellipsis-start", currentPage - 1, currentPage, currentPage + 1, "ellipsis-end", totalPages]; +} function getJobId(job: Job, index: number) { return job.link?.trim() || [job.titulo, job.empresa, job.local, String(index)].filter(Boolean).join("|"); @@ -67,7 +85,6 @@ export function JobsTableCard({ meta, filteredJobs, paginatedJobs, - jobs, loading, error, formatDate, @@ -79,14 +96,14 @@ export function JobsTableCard({ }: JobsTableCardProps) { const [spamMarks, setSpamMarks] = useState>({}); const [readMarks, setReadMarks] = useState>({}); + const resultsLabel = getResultsLabel(filteredJobs.length); + const paginationItems = getPaginationItems(currentPage, totalPages); return ( Vagas Encontradas - - Atualizado em {formatDate(meta.modifiedAt)}. Mostrando {filteredJobs.length} de {jobs.length} vagas. - + Lista paginada com os resultados mais recentes das buscas. {error ? ( @@ -95,53 +112,14 @@ export function JobsTableCard({ ) : null} -
-

- Pagina {currentPage} de {totalPages} -

-
- - - - -
-
- - Titulo + Título Empresa Local Link - Palavras chaves + Palavras-chave Spam Lido Fonte @@ -225,6 +203,84 @@ export function JobsTableCard({ ) : null}
+ +
+

+ {resultsLabel} + (Atualizado em {formatDate(meta.modifiedAt)}) +

+ +
+ + + +
+
); diff --git a/frontend/src/hooks/useJobsPagination.ts b/frontend/src/hooks/useJobsPagination.ts index c847f646..62c003ca 100644 --- a/frontend/src/hooks/useJobsPagination.ts +++ b/frontend/src/hooks/useJobsPagination.ts @@ -1,17 +1,25 @@ -import { useMemo, useState } from "react"; import type { Job } from "@/types/jobs"; +import { useMemo, useState } from "react"; interface UseJobsPaginationParams { filteredJobs: Job[]; initialPageSize?: number; } +function clampPageSize(value: number) { + if (!Number.isFinite(value)) { + return 1; + } + + return Math.min(10, Math.max(1, Math.trunc(value))); +} + export function useJobsPagination({ filteredJobs, - initialPageSize = 25, + initialPageSize = 4, }: UseJobsPaginationParams) { const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(initialPageSize); + const [pageSize, setPageSizeState] = useState(clampPageSize(initialPageSize)); const totalPages = useMemo(() => { return Math.max(1, Math.ceil(filteredJobs.length / pageSize)); @@ -31,6 +39,13 @@ export function useJobsPagination({ }); } + function setPageSize(value: number | ((previous: number) => number)) { + setPageSizeState((previous) => { + const next = typeof value === "function" ? value(previous) : value; + return clampPageSize(next); + }); + } + function resetPagination() { setPage(1); } diff --git a/frontend/tests/unit/components/JobsTableCard.test.tsx b/frontend/tests/unit/components/JobsTableCard.test.tsx index 9b206a25..0a200388 100644 --- a/frontend/tests/unit/components/JobsTableCard.test.tsx +++ b/frontend/tests/unit/components/JobsTableCard.test.tsx @@ -29,17 +29,18 @@ const baseProps = { error: "", formatDate: () => "agora", currentPage: 1, - totalPages: 2, - pageSize: 25, + totalPages: 5, + pageSize: 4, onPageChange: vi.fn(), onPageSizeChange: vi.fn(), }; describe("JobsTableCard", () => { - it("renderiza tabela com palavras-chave em tags e colunas de spam e lido", () => { + it("renderiza tabela com resumo, palavras-chave e colunas de spam e lido", () => { render(); expect(screen.getByText("Vagas Encontradas")).toBeInTheDocument(); + expect(screen.getByText(/Resultados: 1 vaga encontrada/i)).toBeInTheDocument(); expect(screen.getByText("Abrir vaga")).toBeInTheDocument(); expect(screen.getByText("JavaScript")).toBeInTheDocument(); expect(screen.getByText("UX-UI")).toBeInTheDocument(); @@ -47,10 +48,20 @@ describe("JobsTableCard", () => { expect(screen.getByText(/lido/i)).toBeInTheDocument(); }); - it("aciona paginacao", () => { + it("aciona paginacao numerica", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Proxima" })); - expect(baseProps.onPageChange).toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "2" })); + expect(baseProps.onPageChange).toHaveBeenCalledWith(2); + }); + + it("exibe setas para voltar e avancar entre paginas", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /pagina anterior/i })); + fireEvent.click(screen.getByRole("button", { name: /pagina seguinte/i })); + + expect(baseProps.onPageChange).toHaveBeenCalledWith(2); + expect(baseProps.onPageChange).toHaveBeenCalledWith(4); }); it("permite marcar a vaga como spam e lida", () => { diff --git a/frontend/tests/unit/utils/useJobsPagination.test.ts b/frontend/tests/unit/utils/useJobsPagination.test.ts index dc007c69..a412ae12 100644 --- a/frontend/tests/unit/utils/useJobsPagination.test.ts +++ b/frontend/tests/unit/utils/useJobsPagination.test.ts @@ -1,5 +1,5 @@ import { useJobsPagination } from "@/hooks/useJobsPagination"; -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { describe, expect, it } from "vitest"; const JOBS = Array.from({ length: 5 }).map((_, index) => ({ titulo: `Job ${index}` })); @@ -13,7 +13,16 @@ describe("useJobsPagination", () => { it("nao permite pagina menor que 1", () => { const { result } = renderHook(() => useJobsPagination({ filteredJobs: JOBS, initialPageSize: 2 })); - result.current.setCurrentPage(0); + act(() => result.current.setCurrentPage(0)); expect(result.current.currentPage).toBe(1); }); + + it("limita itens por pagina entre 1 e 10", () => { + const { result } = renderHook(() => useJobsPagination({ filteredJobs: JOBS, initialPageSize: 20 })); + + expect(result.current.pageSize).toBe(10); + + act(() => result.current.setPageSize(-3)); + expect(result.current.pageSize).toBe(1); + }); }); diff --git a/package-lock.json b/package-lock.json index 499a284f..e6cf47d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "pdfkit": "^0.18.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/package.json b/package.json index 7d999eb8..505ab5ed 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "pdfkit": "^0.18.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5" }, "devDependencies": { @@ -60,7 +62,9 @@ "target": [ { "target": "nsis", - "arch": ["x64"] + "arch": [ + "x64" + ] } ] },