fix(admin): validate AI settings drafts

This commit is contained in:
rxwatcher
2026-07-28 21:49:41 -04:00
committed by Quick
parent f0bc170113
commit 08035f3806
2 changed files with 146 additions and 16 deletions
@@ -1,8 +1,18 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AIServicesSettings from "./AIServicesSettings";
const mocks = vi.hoisted(() => ({
checkConnection: vi.fn(),
discard: vi.fn(),
save: vi.fn(),
setValue: vi.fn(),
toastError: vi.fn(),
}));
const values: Record<string, string> = {
"ai.base_url": "https://text.example.test",
"ai.chat_model": "chat-model",
@@ -23,15 +33,17 @@ const values: Record<string, string> = {
"metadata_ai.on_view": "button",
};
let dirtyCount = 0;
const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({
isLoading: false,
getValue: (key: string) => values[key] ?? "",
setValue: vi.fn(),
dirtyCount: 0,
setValue: mocks.setValue,
dirtyCount,
dirtyKeys: [],
isDirty: vi.fn(() => false),
save: vi.fn(),
discard: vi.fn(),
save: mocks.save,
discard: mocks.discard,
isSaving: false,
restartRequired: false,
sensitiveConfigured: ["subtitle_ai.api_key"],
@@ -47,10 +59,36 @@ vi.mock("@/hooks/queries/admin/settings", () => ({
useAdminServerSettings: () => ({ data: values }),
useAdminSensitiveStatus: () => ({ data: { configured: ["ai.api_key"] } }),
useUpdateServerSetting: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCheckAdminSettingsConnection: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCheckAdminSettingsConnection: () => ({
mutateAsync: mocks.checkConnection,
isPending: false,
}),
}));
vi.mock("sonner", () => ({
toast: {
error: mocks.toastError,
},
}));
describe("AIServicesSettings", () => {
beforeEach(() => {
dirtyCount = 0;
mocks.checkConnection.mockReset();
mocks.discard.mockReset();
mocks.save.mockReset();
mocks.setValue.mockReset();
mocks.toastError.mockReset();
values["ai.base_url"] = "https://text.example.test";
values["ai.chat_model"] = "chat-model";
values["ai.asr_base_url"] = "";
values["ai.asr_model"] = "whisper-model";
values["ai.max_concurrent_jobs"] = "2";
values["subtitle_ai.batch_size"] = "40";
values["subtitle_ai.context_neighbors"] = "2";
values["subtitle_ai.asr_chunk_seconds"] = "600";
});
it("separates text translation from speech-to-text configuration", () => {
const markup = renderToStaticMarkup(<AIServicesSettings />);
@@ -113,4 +151,84 @@ describe("AIServicesSettings", () => {
expect(markup).toContain('href="/admin/recommendations"');
expect(markup).not.toContain("Changes take effect after a server restart");
});
it("applies a transcription preset", async () => {
const user = userEvent.setup();
render(<AIServicesSettings />);
await user.click(screen.getByRole("button", { name: "Groq - fast" }));
expect(mocks.setValue).toHaveBeenCalledWith("ai.asr_base_url", "https://api.groq.com/openai");
expect(mocks.setValue).toHaveBeenCalledWith("ai.asr_model", "whisper-large-v3-turbo");
});
it("runs both connection checks and clears their results when drafts are discarded", async () => {
const user = userEvent.setup();
dirtyCount = 1;
mocks.checkConnection
.mockResolvedValueOnce({ success: true, message: "Text connection verified." })
.mockResolvedValueOnce({ success: true, message: "Speech connection verified." });
render(<AIServicesSettings />);
await user.click(screen.getByRole("button", { name: "Test Text AI" }));
await user.click(screen.getByRole("button", { name: "Test Speech-to-Text" }));
expect(await screen.findByText("Text connection verified.")).toBeInTheDocument();
expect(await screen.findByText("Speech connection verified.")).toBeInTheDocument();
expect(mocks.checkConnection).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ kind: "ai_chat" }),
);
expect(mocks.checkConnection).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ kind: "ai_transcription" }),
);
await user.click(screen.getByRole("button", { name: "Discard" }));
expect(mocks.discard).toHaveBeenCalledOnce();
await waitFor(() => {
expect(screen.queryByText("Text connection verified.")).not.toBeInTheDocument();
expect(screen.queryByText("Speech connection verified.")).not.toBeInTheDocument();
});
});
it("clears a prior connection result when its endpoint changes", async () => {
const user = userEvent.setup();
mocks.checkConnection.mockResolvedValue({
success: true,
message: "Text connection verified.",
});
render(<AIServicesSettings />);
await user.click(screen.getByRole("button", { name: "Test Text AI" }));
expect(await screen.findByText("Text connection verified.")).toBeInTheDocument();
await user.clear(screen.getByRole("textbox", { name: "Base URL" }));
expect(screen.queryByText("Text connection verified.")).not.toBeInTheDocument();
});
it.each([
["ai.max_concurrent_jobs", "1.5", "Max concurrent jobs must be a positive whole number."],
["subtitle_ai.batch_size", "2abc", "Subtitle batch size must be a positive whole number."],
[
"subtitle_ai.context_neighbors",
"1.5",
"Subtitle context lines must be zero or a positive whole number.",
],
[
"subtitle_ai.asr_chunk_seconds",
"120seconds",
"Transcription chunk length must be between 60 and 600 seconds.",
],
])("rejects malformed integer input for %s", async (key, malformedValue, message) => {
const user = userEvent.setup();
dirtyCount = 1;
values[key] = malformedValue;
render(<AIServicesSettings />);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(mocks.toastError).toHaveBeenCalledWith(message);
expect(mocks.save).not.toHaveBeenCalled();
});
});
@@ -102,6 +102,13 @@ function isChatOnlyGateway(rawURL: string): boolean {
}
}
function parseStrictInteger(rawValue: string): number | null {
const trimmed = rawValue.trim();
if (!/^-?\d+$/.test(trimmed)) return null;
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) ? parsed : null;
}
function SectionHeading({
icon: Icon,
title,
@@ -250,32 +257,31 @@ export default function AIServicesSettings() {
}
async function save() {
const batchSize = Number.parseInt(value("subtitle_ai.batch_size", "40"), 10);
const contextLines = Number.parseInt(value("subtitle_ai.context_neighbors", "2"), 10);
const chunkSeconds = Number.parseInt(value("subtitle_ai.asr_chunk_seconds", "600"), 10);
const batchSize = parseStrictInteger(value("subtitle_ai.batch_size", "40"));
const contextLines = parseStrictInteger(value("subtitle_ai.context_neighbors", "2"));
const chunkSeconds = parseStrictInteger(value("subtitle_ai.asr_chunk_seconds", "600"));
const quotaJobs = Number.parseInt(value("subtitle_ai.transcribe_quota_jobs", "0"), 10);
const maxConcurrent = Number.parseInt(
const maxConcurrent = parseStrictInteger(
effectiveValue("ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs", "2"),
10,
);
if (!textReady) {
toast.error("Text AI base URL and chat model are required.");
return;
}
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
if (maxConcurrent === null || maxConcurrent < 1) {
toast.error("Max concurrent jobs must be a positive whole number.");
return;
}
if (!Number.isInteger(batchSize) || batchSize < 1) {
if (batchSize === null || batchSize < 1) {
toast.error("Subtitle batch size must be a positive whole number.");
return;
}
if (!Number.isInteger(contextLines) || contextLines < 0) {
if (contextLines === null || contextLines < 0) {
toast.error("Subtitle context lines must be zero or a positive whole number.");
return;
}
if (!Number.isInteger(chunkSeconds) || chunkSeconds < 60 || chunkSeconds > 600) {
if (chunkSeconds === null || chunkSeconds < 60 || chunkSeconds > 600) {
toast.error("Transcription chunk length must be between 60 and 600 seconds.");
return;
}
@@ -286,6 +292,12 @@ export default function AIServicesSettings() {
await form.save();
}
function discard() {
form.discard();
setTextResult(null);
setSpeechResult(null);
}
return (
<div className="flex h-full max-w-4xl flex-col">
<div className="mb-6 space-y-2">
@@ -588,7 +600,7 @@ export default function AIServicesSettings() {
<SaveBar
dirtyCount={form.dirtyCount}
onSave={() => void save()}
onDiscard={form.discard}
onDiscard={discard}
isSaving={form.isSaving}
restartRequired={form.restartRequired}
/>