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');
+ });
});