Add RxResume URL setting to dashboard (#258)
* Add RxResume URL setting to dashboard * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f92b80dfe2
commit
f5aef7af24
@ -94,6 +94,7 @@ Important:
|
||||
Configure in **Settings → Reactive Resume**:
|
||||
|
||||
- `rxresumeMode` (`v5` or `v4`)
|
||||
- `rxresumeUrl` (optional shared URL for cloud or self-hosted deployments)
|
||||
- `rxresumeApiKey` (for v5)
|
||||
- `rxresumeEmail` + `rxresumePassword` (for v4)
|
||||
|
||||
@ -105,6 +106,8 @@ Or via environment variables:
|
||||
- `RXRESUME_PASSWORD`
|
||||
- optional `RXRESUME_URL` (works for both modes; v5 OpenAPI path is added automatically)
|
||||
|
||||
If you leave the URL blank in the dashboard, JobOps uses `RXRESUME_URL` when it is set; if not set, it falls back to the public cloud default for the selected mode.
|
||||
|
||||
### 2) Select base resume
|
||||
|
||||
In **Settings → Reactive Resume**:
|
||||
|
||||
@ -109,6 +109,8 @@ Defaults and constraints:
|
||||
|
||||

|
||||
|
||||
- Configure a shared RxResume URL for cloud or self-hosted deployments
|
||||
- Configure v4 email/password or v5 API key in the same section
|
||||
- Select a template/base resume
|
||||
- Configure project selection behavior:
|
||||
- Max projects
|
||||
@ -210,7 +212,9 @@ curl -X POST "http://localhost:3001/api/backups"
|
||||
|
||||
### RxResume controls are disabled
|
||||
|
||||
- Configure RxResume credentials in Environment & Accounts first.
|
||||
- JobOps resolves the RxResume URL in this order: the value saved in **Settings → Reactive Resume**, then the `RXRESUME_URL` environment variable (if set), and finally the public cloud default.
|
||||
- Open **Settings → Reactive Resume** and configure the shared RxResume URL if you use a self-hosted instance.
|
||||
- If you leave the URL blank, JobOps will fall back to `RXRESUME_URL` when it is configured; otherwise it uses the public cloud default.
|
||||
- Then refresh available resumes from the Reactive Resume section.
|
||||
|
||||
### RxResume projects look empty in the RxResume UI
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as api from "@client/api";
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
@ -22,7 +22,18 @@ vi.mock("@client/hooks/useSettings", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
|
||||
SettingsInput: ({ label }: { label: string }) => <div>{label}</div>,
|
||||
SettingsInput: ({
|
||||
label,
|
||||
inputProps,
|
||||
}: {
|
||||
label: string;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}) => (
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input {...inputProps} />
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
|
||||
@ -95,6 +106,7 @@ const settingsResponse = {
|
||||
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
||||
llmApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumeUrl: "",
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumePasswordHint: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
@ -193,4 +205,48 @@ describe("OnboardingGate", () => {
|
||||
});
|
||||
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the RxResume URL field and includes it in validation", async () => {
|
||||
vi.mocked(useSettings).mockReturnValue({
|
||||
...settingsResponse,
|
||||
settings: {
|
||||
...settingsResponse.settings,
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
rxresumeApiKeyHint: "abcd1234",
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||
valid: false,
|
||||
message: "Invalid",
|
||||
});
|
||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("RxResume URL")).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.validateRxresume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://resume.example.com",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("RxResume URL"), {
|
||||
target: { value: "https://self-hosted.example.com" },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByDisplayValue("https://self-hosted.example.com"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -59,6 +59,7 @@ type OnboardingFormData = {
|
||||
llmApiKey: string;
|
||||
rxresumeMode: RxResumeMode;
|
||||
rxresumeEmail: string;
|
||||
rxresumeUrl: string;
|
||||
rxresumePassword: string;
|
||||
rxresumeApiKey: string;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
@ -135,6 +136,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
llmApiKey: "",
|
||||
rxresumeMode: "v5",
|
||||
rxresumeEmail: "",
|
||||
rxresumeUrl: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
rxresumeBaseResumeId: null,
|
||||
@ -287,6 +289,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
llmApiKey: "",
|
||||
rxresumeMode: initialMode,
|
||||
rxresumeEmail: "",
|
||||
rxresumeUrl: settings.rxresumeUrl ?? "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
rxresumeBaseResumeId: selectedId,
|
||||
@ -769,6 +772,10 @@ export const OnboardingGate: React.FC = () => {
|
||||
apiKey: watch("rxresumeApiKey"),
|
||||
onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
|
||||
}}
|
||||
shared={{
|
||||
baseUrl: watch("rxresumeUrl"),
|
||||
onBaseUrlChange: (value) => setValue("rxresumeUrl", value),
|
||||
}}
|
||||
v4={{
|
||||
email: watch("rxresumeEmail"),
|
||||
onEmailChange: (value) => setValue("rxresumeEmail", value),
|
||||
|
||||
@ -62,6 +62,13 @@ type ReactiveResumeConfigPanelProps = {
|
||||
helper?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
shared: {
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
baseUrlError?: string;
|
||||
baseUrlHelper?: string;
|
||||
baseUrlPlaceholder?: string;
|
||||
};
|
||||
v4: {
|
||||
email: string;
|
||||
onEmailChange: (value: string) => void;
|
||||
@ -110,6 +117,7 @@ export const ReactiveResumeConfigPanel: React.FC<
|
||||
showValidationStatus = false,
|
||||
validationStatuses,
|
||||
intro,
|
||||
shared,
|
||||
v5,
|
||||
v4,
|
||||
projectSelection,
|
||||
@ -151,6 +159,25 @@ export const ReactiveResumeConfigPanel: React.FC<
|
||||
|
||||
{mode === "v5" ? (
|
||||
<div className="grid gap-4">
|
||||
<SettingsInput
|
||||
label="RxResume URL"
|
||||
inputProps={{
|
||||
name: "rxresumeUrl",
|
||||
value: shared.baseUrl,
|
||||
onChange: (event) =>
|
||||
shared.onBaseUrlChange(event.currentTarget.value),
|
||||
}}
|
||||
type="url"
|
||||
placeholder={
|
||||
shared.baseUrlPlaceholder ?? "https://resume.example.com"
|
||||
}
|
||||
helper={
|
||||
shared.baseUrlHelper ??
|
||||
"Leave blank to use the default for the selected mode (or the RXRESUME_URL environment override, if set)."
|
||||
}
|
||||
disabled={disabled}
|
||||
error={shared.baseUrlError}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="v5 API key"
|
||||
inputProps={{
|
||||
@ -167,6 +194,27 @@ export const ReactiveResumeConfigPanel: React.FC<
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<SettingsInput
|
||||
label="RxResume URL"
|
||||
inputProps={{
|
||||
name: "rxresumeUrl",
|
||||
value: shared.baseUrl,
|
||||
onChange: (event) =>
|
||||
shared.onBaseUrlChange(event.currentTarget.value),
|
||||
}}
|
||||
type="url"
|
||||
placeholder={
|
||||
shared.baseUrlPlaceholder ?? "https://resume.example.com"
|
||||
}
|
||||
helper={
|
||||
shared.baseUrlHelper ??
|
||||
"Leave blank to use the public cloud default for the selected mode."
|
||||
}
|
||||
disabled={disabled}
|
||||
error={shared.baseUrlError}
|
||||
/>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="v4 Email"
|
||||
inputProps={{
|
||||
|
||||
@ -5,6 +5,7 @@ export type RxResumeSettingsLike =
|
||||
| {
|
||||
rxresumeMode?: { value?: string | null } | null;
|
||||
rxresumeEmail?: string | null;
|
||||
rxresumeUrl?: string | null;
|
||||
rxresumePasswordHint?: string | null;
|
||||
rxresumeApiKeyHint?: string | null;
|
||||
rxresumeBaseResumeId?: string | null;
|
||||
@ -61,10 +62,12 @@ export const getRxResumeBaseResumeSelection = (
|
||||
|
||||
export const getRxResumeCredentialDrafts = (input: {
|
||||
rxresumeEmail?: string | null;
|
||||
rxresumeUrl?: string | null;
|
||||
rxresumePassword?: string | null;
|
||||
rxresumeApiKey?: string | null;
|
||||
}) => ({
|
||||
email: input.rxresumeEmail?.trim() ?? "",
|
||||
baseUrl: input.rxresumeUrl?.trim() ?? "",
|
||||
password: input.rxresumePassword?.trim() ?? "",
|
||||
apiKey: input.rxresumeApiKey?.trim() ?? "",
|
||||
});
|
||||
@ -116,6 +119,7 @@ export const toRxResumeValidationPayload = (
|
||||
draft: RxResumeCredentialDrafts,
|
||||
) => ({
|
||||
email: draft.email || undefined,
|
||||
baseUrl: draft.baseUrl || undefined,
|
||||
password: draft.password || undefined,
|
||||
apiKey: draft.apiKey || undefined,
|
||||
});
|
||||
@ -126,6 +130,7 @@ export const buildRxResumeSettingsUpdate = (
|
||||
): Partial<UpdateSettingsInput> => {
|
||||
const update: Partial<UpdateSettingsInput> = {
|
||||
rxresumeMode: mode,
|
||||
rxresumeUrl: draft.baseUrl || null,
|
||||
};
|
||||
if (draft.email) update.rxresumeEmail = draft.email;
|
||||
if (draft.password) update.rxresumePassword = draft.password;
|
||||
|
||||
@ -276,6 +276,38 @@ describe("SettingsPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a shared RxResume URL from the Reactive Resume section", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
const reactiveResumeTrigger = await screen.findByRole("button", {
|
||||
name: /reactive resume/i,
|
||||
});
|
||||
fireEvent.click(reactiveResumeTrigger);
|
||||
|
||||
const urlInput = screen.getByLabelText(/rxresume url/i);
|
||||
await waitFor(() => expect(urlInput).toBeEnabled());
|
||||
fireEvent.change(urlInput, {
|
||||
target: { value: "https://resume.example.com" },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves the writing language mode through the settings page", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||
|
||||
@ -75,6 +75,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumeUrl: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: "",
|
||||
@ -132,6 +133,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumeUrl: null,
|
||||
rxresumePassword: null,
|
||||
rxresumeApiKey: null,
|
||||
basicAuthUser: null,
|
||||
@ -174,6 +176,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null,
|
||||
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumeUrl: data.rxresumeUrl ?? "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
@ -715,6 +718,10 @@ export const SettingsPage: React.FC = () => {
|
||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
|
||||
}
|
||||
|
||||
if (dirtyFields.rxresumeUrl) {
|
||||
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
const rxresumeApiKeyValue =
|
||||
useWatch({ control, name: "rxresumeApiKey" }) ?? "";
|
||||
const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? "";
|
||||
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
|
||||
const rxresumePasswordValue =
|
||||
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
||||
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
||||
@ -85,6 +86,12 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
showValidationStatus={Boolean(validationStatuses)}
|
||||
validationStatuses={validationStatuses}
|
||||
shared={{
|
||||
baseUrl: rxresumeUrlValue,
|
||||
onBaseUrlChange: (value) =>
|
||||
setDirtyTouchedValue("rxresumeUrl", value),
|
||||
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
|
||||
}}
|
||||
v5={{
|
||||
apiKey: rxresumeApiKeyValue,
|
||||
onApiKeyChange: (value) =>
|
||||
|
||||
@ -69,6 +69,7 @@ describe.sequential("Settings API routes", () => {
|
||||
env: {
|
||||
LLM_API_KEY: "secret-key",
|
||||
RXRESUME_EMAIL: "resume@example.com",
|
||||
RXRESUME_URL: "https://env.rxresume.example.com",
|
||||
},
|
||||
}));
|
||||
});
|
||||
@ -84,6 +85,7 @@ describe.sequential("Settings API routes", () => {
|
||||
expect(body.data.model.default).toBe("test-model");
|
||||
expect(Array.isArray(body.data.searchTerms.value)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe("resume@example.com");
|
||||
expect(body.data.rxresumeUrl).toBe("https://env.rxresume.example.com");
|
||||
expect(body.data.llmApiKeyHint).toBe("secr");
|
||||
expect(body.data.basicAuthActive).toBe(false);
|
||||
});
|
||||
@ -124,6 +126,7 @@ describe.sequential("Settings API routes", () => {
|
||||
body: JSON.stringify({
|
||||
searchTerms: ["engineer"],
|
||||
rxresumeEmail: "updated@example.com",
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
llmApiKey: "updated-secret",
|
||||
}),
|
||||
});
|
||||
@ -132,6 +135,7 @@ describe.sequential("Settings API routes", () => {
|
||||
expect(patchBody.data.searchTerms.value).toEqual(["engineer"]);
|
||||
expect(patchBody.data.searchTerms.override).toEqual(["engineer"]);
|
||||
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
||||
expect(patchBody.data.rxresumeUrl).toBe("https://resume.example.com");
|
||||
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
||||
});
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ describe("rxresume adapter", () => {
|
||||
delete process.env.RXRESUME_API_KEY;
|
||||
delete process.env.RXRESUME_EMAIL;
|
||||
delete process.env.RXRESUME_PASSWORD;
|
||||
delete process.env.RXRESUME_URL;
|
||||
delete process.env.RXRESUME_MODE;
|
||||
mockSettings({});
|
||||
});
|
||||
@ -157,6 +158,44 @@ describe("rxresume adapter", () => {
|
||||
expect(result).toEqual({ ok: true, mode: "v4" });
|
||||
});
|
||||
|
||||
it("prefers stored rxresumeUrl over environment values", async () => {
|
||||
process.env.RXRESUME_URL = "https://env.rxresume.example.com";
|
||||
mockSettings({
|
||||
rxresumeMode: "v4",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
rxresumeUrl: "https://stored.rxresume.example.com",
|
||||
});
|
||||
vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true });
|
||||
|
||||
await validateCredentials();
|
||||
|
||||
expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
"pw",
|
||||
"https://stored.rxresume.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default v4 URL when no env or stored URL is configured", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v4",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
});
|
||||
vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true });
|
||||
|
||||
await validateCredentials({
|
||||
v4: { baseUrl: " " },
|
||||
});
|
||||
|
||||
expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
"pw",
|
||||
"https://v4.rxresu.me",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to v4 validation when explicit v5 validation fails", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v5",
|
||||
|
||||
@ -172,36 +172,42 @@ async function readConfiguredMode(): Promise<RxResumeMode> {
|
||||
}
|
||||
|
||||
async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
|
||||
const [storedEmail, storedPassword] = await Promise.all([
|
||||
const [storedEmail, storedPassword, storedBaseUrl] = await Promise.all([
|
||||
getSetting("rxresumeEmail"),
|
||||
getSetting("rxresumePassword"),
|
||||
getSetting("rxresumeUrl"),
|
||||
]);
|
||||
const email =
|
||||
overrides?.email?.trim() ||
|
||||
process.env.RXRESUME_EMAIL?.trim() ||
|
||||
storedEmail?.trim() ||
|
||||
process.env.RXRESUME_EMAIL?.trim() ||
|
||||
"";
|
||||
const password =
|
||||
overrides?.password?.trim() ||
|
||||
process.env.RXRESUME_PASSWORD?.trim() ||
|
||||
storedPassword?.trim() ||
|
||||
process.env.RXRESUME_PASSWORD?.trim() ||
|
||||
"";
|
||||
const baseUrl =
|
||||
overrides?.baseUrl?.trim() ||
|
||||
storedBaseUrl?.trim() ||
|
||||
process.env.RXRESUME_URL?.trim() ||
|
||||
"https://v4.rxresu.me";
|
||||
return { email, password, baseUrl, available: Boolean(email && password) };
|
||||
}
|
||||
|
||||
async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) {
|
||||
const [storedApiKey] = await Promise.all([getSetting("rxresumeApiKey")]);
|
||||
const [storedApiKey, storedBaseUrl] = await Promise.all([
|
||||
getSetting("rxresumeApiKey"),
|
||||
getSetting("rxresumeUrl"),
|
||||
]);
|
||||
const apiKey =
|
||||
overrides?.apiKey?.trim() ||
|
||||
process.env.RXRESUME_API_KEY?.trim() ||
|
||||
storedApiKey?.trim() ||
|
||||
process.env.RXRESUME_API_KEY?.trim() ||
|
||||
"";
|
||||
const baseUrl =
|
||||
overrides?.baseUrl?.trim() ||
|
||||
storedBaseUrl?.trim() ||
|
||||
process.env.RXRESUME_URL?.trim() ||
|
||||
"https://rxresu.me";
|
||||
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
||||
|
||||
@ -116,6 +116,10 @@ describe("settingsRegistry helpers", () => {
|
||||
it("has env-backed v5 api key secret setting", () => {
|
||||
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
|
||||
});
|
||||
|
||||
it("has env-backed rxresumeUrl string setting", () => {
|
||||
expect(settingsRegistry.rxresumeUrl.envKey).toBe("RXRESUME_URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing-style language settings", () => {
|
||||
|
||||
@ -462,6 +462,14 @@ export const settingsRegistry = {
|
||||
envKey: "RXRESUME_EMAIL",
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeUrl: {
|
||||
kind: "string" as const,
|
||||
envKey: "RXRESUME_URL",
|
||||
schema: z.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z.string().trim().url().max(2000).nullable(),
|
||||
),
|
||||
},
|
||||
ukvisajobsEmail: {
|
||||
kind: "string" as const,
|
||||
envKey: "UKVISAJOBS_EMAIL",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateSettingsSchema } from "./settings-schema";
|
||||
|
||||
describe("updateSettingsSchema language settings", () => {
|
||||
describe("updateSettingsSchema", () => {
|
||||
it("accepts supported language mode and manual language values", () => {
|
||||
expect(
|
||||
updateSettingsSchema.parse({
|
||||
@ -43,4 +43,32 @@ describe("updateSettingsSchema language settings", () => {
|
||||
result.error.flatten().fieldErrors.chatStyleManualLanguage,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts a nullable rxresumeUrl and rejects invalid URLs", () => {
|
||||
expect(
|
||||
updateSettingsSchema.parse({
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
}),
|
||||
).toEqual({
|
||||
rxresumeUrl: "https://resume.example.com",
|
||||
});
|
||||
|
||||
expect(
|
||||
updateSettingsSchema.parse({
|
||||
rxresumeUrl: null,
|
||||
}),
|
||||
).toEqual({
|
||||
rxresumeUrl: null,
|
||||
});
|
||||
|
||||
const result = updateSettingsSchema.safeParse({
|
||||
rxresumeUrl: "not-a-url",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.error.flatten().fieldErrors.rxresumeUrl).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -201,6 +201,7 @@ export const createAppSettings = (
|
||||
llmApiKeyHint: null,
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumeUrl: null,
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPasswordHint: null,
|
||||
|
||||
@ -183,6 +183,7 @@ export interface AppSettings {
|
||||
rxresumeBaseResumeIdV4: string | null;
|
||||
rxresumeBaseResumeIdV5: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumeUrl: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
adzunaAppId: string | null;
|
||||
basicAuthUser: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user