Jobber/orchestrator/src/server/api/routes/onboarding.test.ts
Shaheer Sarfaraz 7514aa1b28
Add RxResume v4/v5 dual support (#230)
* feat(settings): add rxresume mode and v5 api key settings

* feat(server): add mode-aware rxresume adapter with auto v5-first selection

* refactor(server): route settings profile and pdf generation through rxresume adapter

* feat(api): support rxresume v4/v5 in onboarding and settings routes with ok/meta responses

* feat(client): add rxresume mode selector and v5 api key setup flow

* docs: document rxresume auto mode with v5-first self-hosted setup

* test: verify dual-mode rxresume support and ci parity checks

* comments

* services folder

* correct types for v5

* tests and docs fix

* Fix RxResume auto fallback and route API consistency

* warning for both being set

* simpler response

* onboarding component improvements, v5 check still not working

* fix list resume endpoint...

* fix api endpoints to latest v5 docs

* don't show the entire project field on v5

* remove auto entirely

* formatting

* ci green

* v5 has a different resume schema

* remove redundant check

* remove requirement that only one must be specified

* consolidate sections

* base resume can be v4 or v5

* saving now works

* status indicator

* actually render some pills

* reason for failure

* fix apikey verification

* dedupe isValidatingMode

* reefactoor

* simplification?

* refactor?

* ci passing

* remove auto from docs

* tailoring is schema dependent

* skills object tighter

* remove redundant text

* fix lint

* mode
2026-02-25 02:26:15 +00:00

493 lines
15 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);
});
});
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");
});
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);
});
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();
});
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");
});
});
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: "",
},
};
}