diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx new file mode 100644 index 0000000..b9a0232 --- /dev/null +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { JobHeader } from "./JobHeader"; +import { useSettings } from "../hooks/useSettings"; +import * as api from "../api"; +import type { Job } from "../../shared/types"; + +// Mock useSettings +vi.mock("../hooks/useSettings", () => ({ + useSettings: vi.fn(), +})); + +// Mock api +vi.mock("../api", () => ({ + checkSponsor: vi.fn(), +})); + +// Mock Tooltip components to simplify testing +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const mockJob: Job = { + id: "job-1", + title: "Software Engineer", + employer: "Tech Corp", + location: "London", + salary: "£60,000", + deadline: "2025-12-31", + status: "discovered", + source: "linkedin", + suitabilityScore: 85, + suitabilityReason: "Strong match", + sponsorMatchScore: null, + sponsorMatchNames: null, + // Other fields... +} as Job; + +describe("JobHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + (useSettings as any).mockReturnValue({ + showSponsorInfo: true, + }); + }); + + it("renders basic job information", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Tech Corp")).toBeInTheDocument(); + expect(screen.getByText("London")).toBeInTheDocument(); + expect(screen.getByText("£60,000")).toBeInTheDocument(); + }); + + it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { + const onCheckSponsor = vi.fn().mockResolvedValue(undefined); + render(); + + const button = screen.getByText("Check Sponsorship Status"); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + expect(onCheckSponsor).toHaveBeenCalled(); + }); + + it("shows 'Confirmed Sponsor' when score >= 95", () => { + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' }; + render(); + + expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Potential Sponsor' when score is between 80 and 94", () => { + const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' }; + render(); + + expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Sponsor Not Found' when score < 80", () => { + const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' }; + render(); + + expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); + }); + + it("hides sponsor info when showSponsorInfo is false", () => { + (useSettings as any).mockReturnValue({ + showSponsorInfo: false, + }); + + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; + render(); + + expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); + expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts new file mode 100644 index 0000000..e8aff35 --- /dev/null +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSettings, _resetSettingsCache } from './useSettings'; +import * as api from '../api'; + +vi.mock('../api', () => ({ + getSettings: vi.fn(), +})); + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetSettingsCache(); + }); + + it('fetches settings on mount if not already cached', async () => { + const mockSettings = { showSponsorInfo: false }; + (api.getSettings as any).mockResolvedValue(mockSettings); + + const { result } = renderHook(() => useSettings()); + + // Should start in loading state + expect(result.current.settings).toBeNull(); + + await waitFor(() => { + expect(result.current.settings).toEqual(mockSettings); + }); + + expect(result.current.showSponsorInfo).toBe(false); + expect(api.getSettings).toHaveBeenCalledTimes(1); + }); + + it('uses default values when settings are null', async () => { + (api.getSettings as any).mockResolvedValue(null); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + // settings is null, so showSponsorInfo should default to true + expect(result.current.showSponsorInfo).toBe(true); + }); + }); + + it('provides a refresh function that updates settings', async () => { + const initialSettings = { showSponsorInfo: true }; + const updatedSettings = { showSponsorInfo: false }; + + (api.getSettings as any).mockResolvedValueOnce(initialSettings); + (api.getSettings as any).mockResolvedValueOnce(updatedSettings); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settings).toEqual(initialSettings); + }); + + let refreshed; + await waitFor(async () => { + refreshed = await result.current.refreshSettings(); + }); + + expect(refreshed).toEqual(updatedSettings); + expect(result.current.settings).toEqual(updatedSettings); + expect(result.current.showSponsorInfo).toBe(false); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index c970690..cb8fae3 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -56,3 +56,10 @@ export function useSettings() { refreshSettings, }; } + +/** @internal For testing only */ +export function _resetSettingsCache() { + settingsCache = null; + isFetching = false; + subscribers.clear(); +} diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 14324de..7fba136 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -95,4 +95,26 @@ describe.sequential('Jobs API routes', () => { }) ); }); + + it('checks visa sponsor status for a job', async () => { + const { searchSponsors } = await import('../../services/visa-sponsors/index.js'); + vi.mocked(searchSponsors).mockReturnValue([ + { sponsor: { organisationName: 'ACME CORP SPONSOR' } as any, score: 100, matchedName: 'acme corp sponsor' } + ]); + + const { createJob } = await import('../../repositories/jobs.js'); + const job = await createJob({ + source: 'manual', + title: 'Sponsored Dev', + employer: 'Acme', + jobUrl: 'https://example.com/job/4', + }); + + const res = await fetch(`${baseUrl}/api/jobs/${job.id}/check-sponsor`, { method: 'POST' }); + const body = await res.json(); + + expect(body.success).toBe(true); + expect(body.data.sponsorMatchScore).toBe(100); + expect(body.data.sponsorMatchNames).toContain('ACME CORP SPONSOR'); + }); });