diff --git a/orchestrator/src/client/components/ManualImportSheet.test.tsx b/orchestrator/src/client/components/ManualImportSheet.test.tsx new file mode 100644 index 0000000..a2f2967 --- /dev/null +++ b/orchestrator/src/client/components/ManualImportSheet.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +import { ManualImportSheet } from "./ManualImportSheet"; +import * as api from "../api"; +import { toast } from "sonner"; + +vi.mock("../api", () => ({ + inferManualJob: vi.fn(), + importManualJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("ManualImportSheet", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("runs analyze -> review -> import on the happy path", async () => { + const rawDescription = " Backend Engineer role in London. "; + const onOpenChange = vi.fn(); + const onImported = vi.fn().mockResolvedValue(undefined); + + vi.mocked(api.inferManualJob).mockResolvedValue({ + job: { + title: "Backend Engineer", + employer: "Acme Labs", + location: "London, UK", + }, + }); + vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("Paste the full job description here..."), + { target: { value: rawDescription } } + ); + fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); + + const titleInput = await screen.findByPlaceholderText("e.g. Junior Backend Engineer"); + expect(titleInput).toHaveValue("Backend Engineer"); + + const jdTextarea = screen.getByPlaceholderText("Paste the job description...") as HTMLTextAreaElement; + expect(jdTextarea.value).toBe(rawDescription.trim()); + + fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), { + target: { value: " 120k " }, + }); + + fireEvent.click(screen.getByRole("button", { name: /import job/i })); + + await waitFor(() => expect(api.importManualJob).toHaveBeenCalled()); + expect(api.importManualJob).toHaveBeenCalledWith({ + job: expect.objectContaining({ + title: "Backend Engineer", + employer: "Acme Labs", + location: "London, UK", + salary: "120k", + jobDescription: rawDescription.trim(), + }), + }); + + await waitFor(() => expect(onImported).toHaveBeenCalledWith("job-1")); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(toast.success).toHaveBeenCalledWith( + "Job imported", + expect.objectContaining({ + description: expect.any(String), + }) + ); + }); + + it("shows warnings and requires required fields before import", async () => { + const rawDescription = "Manual QA Engineer role."; + + vi.mocked(api.inferManualJob).mockResolvedValue({ + job: {}, + warning: "AI inference failed. Fill details manually.", + }); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("Paste the full job description here..."), + { target: { value: rawDescription } } + ); + fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); + + await screen.findByText("AI inference failed. Fill details manually."); + + const importButton = screen.getByRole("button", { name: /import job/i }); + expect(importButton).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("e.g. Junior Backend Engineer"), { + target: { value: "QA Engineer" }, + }); + fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), { + target: { value: "Acme Labs" }, + }); + + await waitFor(() => expect(importButton).toBeEnabled()); + }); + + it("returns to the paste step when inference fails", async () => { + const rawDescription = "Backend role description."; + + vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed")); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("Paste the full job description here..."), + { target: { value: rawDescription } } + ); + fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); + + await screen.findByText("Inference failed"); + expect(screen.getByRole("button", { name: /analyze jd/i })).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText("e.g. Junior Backend Engineer") + ).not.toBeInTheDocument(); + }); + + it("shows a toast error and keeps the sheet open when import fails", async () => { + vi.mocked(api.inferManualJob).mockResolvedValue({ + job: { + title: "Backend Engineer", + employer: "Acme Labs", + }, + }); + vi.mocked(api.importManualJob).mockRejectedValue(new Error("Import failed")); + + const onOpenChange = vi.fn(); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("Paste the full job description here..."), + { target: { value: "Backend Engineer role." } } + ); + fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); + + await screen.findByPlaceholderText("e.g. Junior Backend Engineer"); + + fireEvent.click(screen.getByRole("button", { name: /import job/i })); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Import failed") + ); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled(); + }); +}); diff --git a/orchestrator/src/server/services/manualJob.test.ts b/orchestrator/src/server/services/manualJob.test.ts new file mode 100644 index 0000000..00321c4 --- /dev/null +++ b/orchestrator/src/server/services/manualJob.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as settingsRepo from "../repositories/settings.js"; +import { inferManualJobDetails } from "./manualJob.js"; + +vi.mock("../repositories/settings.js", () => ({ + getSetting: vi.fn(), +})); + +const originalEnv = process.env; +const originalFetch = global.fetch; + +describe("manual job inference", () => { + beforeEach(() => { + vi.resetAllMocks(); + process.env = { ...originalEnv, OPENROUTER_API_KEY: "test-key" }; + global.fetch = vi.fn(); + vi.mocked(settingsRepo.getSetting).mockResolvedValue(null); + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns a warning when the API key is missing", async () => { + delete process.env.OPENROUTER_API_KEY; + + const result = await inferManualJobDetails("JD text"); + + expect(result.job).toEqual({}); + expect(result.warning).toContain("OPENROUTER_API_KEY not set"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("parses JSON even when wrapped in markdown fences", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: + "Here is the data: ```json\n{ \"title\": \"Backend Engineer\", \"employer\": \"Acme\", \"salary\": \" 100k \" }\n```", + }, + }, + ], + }), + } as any); + + const result = await inferManualJobDetails("JD text"); + + expect(result.warning).toBeUndefined(); + expect(result.job).toMatchObject({ + title: "Backend Engineer", + employer: "Acme", + salary: "100k", + }); + }); + + it("returns a warning when the API response fails", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 500, + } as any); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await inferManualJobDetails("JD text"); + + expect(result.job).toEqual({}); + expect(result.warning).toContain("AI inference failed"); + warnSpy.mockRestore(); + }); +});