fix(admin): validate AI settings drafts
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user