Jobber/orchestrator/src/server/api/routes/onboarding.test.ts
Ammad Ali ac0a1281f4
Enhance RxResume validation, settings handling, and caching (#287)
* feat: enhance validation handling in ReactiveResumeConfigPanel and rxresume-config

* feat: enhance RxResume validation handling in SettingsPage and ReactiveResumeSection

* feat: enhance RxResume settings handling and cache management

* feat: add save-time validation and caching for Reactive Resume settings

* refactor: improve code formatting and readability across multiple files

* fix: improve condition check in hasOverrideKey function

* feat: enhance RxResume validation and settings handling with precheck options

* refactor: streamline RxResume client initialization and improve backup sorting logic
2026-03-19 11:38:04 +00:00

674 lines
21 KiB
TypeScript

import type { Server } from "node:http";
import { RxResumeClient } from "@server/services/rxresume/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils";
describe.sequential("Onboarding API routes", () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
let originalFetch: typeof global.fetch;
beforeEach(async () => {
originalFetch = global.fetch;
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
global.fetch = originalFetch;
});
describe("POST /api/onboarding/validate/openrouter", () => {
it("returns invalid when no API key is provided and none in env", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing");
});
it("returns invalid when API key is empty string", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: " " }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing");
});
it("validates an invalid API key against OpenRouter", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.startsWith("https://openrouter.ai/api/v1/key")) {
return Promise.resolve({
ok: false,
status: 401,
json: async () => ({ error: { message: "invalid api key" } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "sk-or-invalid-key-12345" }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
// Should be invalid because the key is fake
expect(body.data.valid).toBe(false);
});
});
describe("POST /api/onboarding/validate/llm", () => {
it("maps Gemini 403 key validation failures to an invalid-key message", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (
url.startsWith(
"https://generativelanguage.googleapis.com/v1beta/models?",
)
) {
return Promise.resolve({
ok: false,
status: 403,
json: async () => ({
error: {
code: 403,
message:
"Method doesn't allow unregistered callers. Please use API key.",
status: "PERMISSION_DENIED",
},
}),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "gemini",
apiKey: "invalid-gemini-key",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBe(
"Invalid LLM API key. Check the key and try again.",
);
});
it("ignores baseUrl for Gemini and validates against the Gemini API", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (
url.startsWith(
"https://generativelanguage.googleapis.com/v1beta/models?",
)
) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ models: [] }),
} as Response);
}
if (url.startsWith("http://localhost:1234")) {
return Promise.resolve({
ok: false,
status: 401,
json: async () => ({ error: { message: "bad local auth" } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "gemini",
apiKey: "valid-gemini-key",
baseUrl: "http://localhost:1234",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
});
it("falls back to stored settings when request omits apiKey", async () => {
await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
llmProvider: "gemini",
llmApiKey: "db-gemini-key",
}),
});
delete process.env.LLM_API_KEY;
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (
url.startsWith(
"https://generativelanguage.googleapis.com/v1beta/models?",
)
) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ models: [] }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: "gemini" }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
const requestInput = call[0];
if (typeof requestInput === "string") return requestInput;
if (requestInput instanceof URL) return requestInput.href;
return requestInput.url;
});
expect(
fetchCalls.some((url) =>
url.includes(
"https://generativelanguage.googleapis.com/v1beta/models?key=db-gemini-key",
),
),
).toBe(true);
});
it("uses the provided baseUrl for the hyphenated OpenAI-compatible alias", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.startsWith("https://llm.example.com/v1/models")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ data: [] }),
} as Response);
}
if (url.startsWith("https://api.openai.com/v1/models")) {
return Promise.resolve({
ok: false,
status: 500,
json: async () => ({ error: { message: "wrong endpoint used" } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "openai-compatible",
apiKey: "test-compatible-key",
baseUrl: "https://llm.example.com/v1/",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
const requestInput = call[0];
if (typeof requestInput === "string") return requestInput;
if (requestInput instanceof URL) return requestInput.href;
return requestInput.url;
});
expect(
fetchCalls.some((url) =>
url.startsWith("https://llm.example.com/v1/models"),
),
).toBe(true);
expect(
fetchCalls.some((url) =>
url.startsWith("https://api.openai.com/v1/models"),
),
).toBe(false);
});
it("does not reuse a stored baseUrl when openai-compatible validation is submitted with a blank baseUrl", async () => {
await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
llmProvider: "openai_compatible",
llmApiKey: "stored-compatible-key",
llmBaseUrl: "https://stale.example.com/v1/",
}),
});
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.startsWith("https://api.openai.com/v1/models")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ data: [] }),
} as Response);
}
if (url.startsWith("https://stale.example.com/v1/models")) {
return Promise.resolve({
ok: false,
status: 500,
json: async () => ({ error: { message: "stale endpoint used" } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "openai-compatible",
apiKey: "test-compatible-key",
baseUrl: " ",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
const requestInput = call[0];
if (typeof requestInput === "string") return requestInput;
if (requestInput instanceof URL) return requestInput.href;
return requestInput.url;
});
expect(
fetchCalls.some((url) =>
url.startsWith("https://api.openai.com/v1/models"),
),
).toBe(true);
expect(
fetchCalls.some((url) =>
url.startsWith("https://stale.example.com/v1/models"),
),
).toBe(false);
});
});
describe("POST /api/onboarding/validate/rxresume", () => {
it("returns invalid when no credentials are provided and none in env", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
expect(body.data.status).toBe(400);
});
it("returns invalid when only email is provided", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com" }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
});
it("returns invalid when only password is provided", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "testpass" }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
});
it("validates invalid credentials against RxResume", async () => {
vi.spyOn(RxResumeClient, "verifyCredentials").mockResolvedValue({
ok: false,
status: 401,
message: "InvalidCredentials",
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "nonexistent@test.com",
password: "wrongpassword123",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
// Should be invalid because credentials are fake
expect(body.data.valid).toBe(false);
expect(body.data.status).toBe(401);
expect(body.data.message).toContain("email/password");
});
it("returns a v5 API-key specific warning for invalid v5 credentials", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.includes("/api/openapi/resumes")) {
return Promise.resolve({
ok: false,
status: 401,
headers: { get: () => "application/json" },
json: async () => ({ message: "Unauthorized" }),
} as unknown as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "v5",
apiKey: "rr-v5-invalid-key",
baseUrl: "http://localhost:3000",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.status).toBe(401);
expect(body.data.message).toContain("API key");
});
it("returns an availability warning when the Reactive Resume instance is unreachable", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.includes("/api/openapi/resumes")) {
return Promise.reject(new TypeError("fetch failed"));
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "v5",
apiKey: "rr-v5-test-key",
baseUrl: "http://localhost:3000",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.status).toBe(0);
expect(body.data.message).toContain("http://localhost:3000");
expect(body.data.message).toContain("unavailable");
});
it("validates v5 API key mode against Reactive Resume OpenAPI", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.includes("/api/openapi/resumes")) {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => [],
} as unknown as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "v5",
apiKey: "rr-v5-test-key",
baseUrl: "http://localhost:3000",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
expect(body.data.status).toBeNull();
});
it("handles whitespace-only credentials", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: " ", password: " " }),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
expect(body.data.status).toBe(400);
});
});
describe("GET /api/onboarding/validate/resume", () => {
it("returns invalid when rxresumeBaseResumeId is not configured", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("No base resume selected");
});
// Note: Further validation tests require mocking getSetting and getResume
// which is complex in integration tests. The validation logic is covered
// by unit tests in profile.test.ts and the service tests.
});
});
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function _createMinimalValidResume() {
return {
basics: {
name: "Test User",
headline: "Software Developer",
email: "test@example.com",
phone: "",
location: "",
url: { label: "", href: "" },
customFields: [],
picture: {
url: "",
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: {
id: "summary",
name: "Summary",
columns: 1,
separateLinks: true,
visible: true,
content: "",
},
skills: {
id: "skills",
name: "Skills",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
awards: {
id: "awards",
name: "Awards",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
certifications: {
id: "certifications",
name: "Certifications",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
education: {
id: "education",
name: "Education",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
experience: {
id: "experience",
name: "Experience",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
volunteer: {
id: "volunteer",
name: "Volunteer",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
interests: {
id: "interests",
name: "Interests",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
languages: {
id: "languages",
name: "Languages",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
profiles: {
id: "profiles",
name: "Profiles",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
projects: {
id: "projects",
name: "Projects",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
publications: {
id: "publications",
name: "Publications",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
references: {
id: "references",
name: "References",
columns: 1,
separateLinks: true,
visible: true,
items: [],
},
custom: {},
},
metadata: {
template: "rhyhorn",
layout: [[["summary"], ["skills"]]],
css: { value: "", visible: false },
page: {
margin: 18,
format: "a4",
options: { breakLine: true, pageNumbers: true },
},
theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" },
typography: {
font: {
family: "IBM Plex Serif",
subset: "latin",
variants: ["regular"],
size: 14,
},
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: "",
},
};
}