fix(admin): persist JSON extra_config при свёрнутой textarea (#1239) #1289

Merged
bot-backend merged 1 commit from fix/job-settings-persist-v3 into main 2026-06-13 08:20:26 +00:00
2 changed files with 224 additions and 1 deletions

View file

@ -145,8 +145,22 @@ export function JobSettingsPanel() {
const draft = drafts[job_type];
if (!draft) return;
// Compare draft JSON-text против серверного значения, чтобы не терять правки,
// когда textarea свёрнута (issue #1239: при collapsed expanded[job_type]
// парсинг пропускался, PUT уходил без extra_config, бэкенд оставлял старое
// значение, onSuccess перетирал draft.extra_config_text).
const serverRow = settings.data?.find(
(s: JobSettingRead) => s.job_type === job_type,
);
const serverExtraConfigText = serverRow
? JSON.stringify(serverRow.extra_config, null, 2)
: null;
const extraConfigDirty =
serverExtraConfigText !== null &&
draft.extra_config_text !== serverExtraConfigText;
let extra_config: Record<string, unknown> | undefined;
if (expanded[job_type]) {
if (extraConfigDirty) {
try {
const parsed = JSON.parse(draft.extra_config_text) as unknown;
if (
@ -157,6 +171,11 @@ export function JobSettingsPanel() {
patchDraft(job_type, {
extra_config_error: "Должен быть JSON-объект (не массив и не null)",
});
// Раскрыть строку, иначе пользователь не увидит сообщение об ошибке.
setExpanded((prev: Record<string, boolean>) => ({
...prev,
[job_type]: true,
}));
return;
}
extra_config = parsed as Record<string, unknown>;
@ -165,6 +184,10 @@ export function JobSettingsPanel() {
patchDraft(job_type, {
extra_config_error: `Невалидный JSON: ${(e as Error).message}`,
});
setExpanded((prev: Record<string, boolean>) => ({
...prev,
[job_type]: true,
}));
return;
}
}

View file

@ -0,0 +1,200 @@
/**
* Tests for `JobSettingsPanel` regression coverage для issue #1239.
*
* Bug: правки JSON `extra_config` молча терялись, если textarea свёрнута при
* сохранении. handleSave парсил `draft.extra_config_text` только когда
* `expanded[job_type] === true` после клика « скрыть» PUT уходил без
* `extra_config`, бэкенд (PATCH-семантика `if extra_config is not None`) не
* трогал поле и возвращал старый JSON, onSuccess перетирал draft через
* `toRowDraft(data)`, а toast «сохранено» давал ложное подтверждение.
*
* Fix: сравнивать `draft.extra_config_text` с `JSON.stringify(server
* extra_config, null, 2)` и слать `extra_config` всякий раз, когда draft
* отличается от server-baseline независимо от `expanded[]`.
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { JobSettingsPanel } from "../JobSettingsPanel";
// Mock apiFetch (используется и для GET listing, и для PUT update). Возвращаем
// разные ответы в зависимости от method'а: GET → server list, PUT → echo
// updated row (имитируя backend, который сохраняет переданный extra_config).
type JobSettingRead = {
job_type: string;
enabled: boolean;
queue_name: string;
cron_schedule: string | null;
rate_ms: number | null;
max_retries: number;
max_concurrency: number;
extra_config: Record<string, unknown>;
updated_at: string;
updated_by: string | null;
description: string | null;
};
const SERVER_ROW: JobSettingRead = {
job_type: "noise_loader",
enabled: true,
queue_name: "default",
cron_schedule: "*/15 * * * *",
rate_ms: null,
max_retries: 3,
max_concurrency: 1,
extra_config: { timeout_s: 30, retries: 2 },
updated_at: "2026-06-01T10:00:00Z",
updated_by: "admin",
description: "Загрузчик шумовых карт",
};
let putBodies: Array<Record<string, unknown>> = [];
const apiFetchMock = vi.fn(
(path: string, init?: RequestInit): Promise<unknown> => {
if (!init || init.method === undefined) {
// GET /api/v1/admin/jobs/settings
return Promise.resolve([SERVER_ROW]);
}
if (init.method === "PUT") {
const body = JSON.parse(
typeof init.body === "string" ? init.body : "{}",
) as Record<string, unknown>;
putBodies.push(body);
// Эмуляция PATCH-семантики бэкенда: если поле не передано — сохраняем
// старое серверное значение. Именно это поведение делает баг «молчаливым»
// (toast «сохранено» появляется, но extra_config не изменился).
const updated: JobSettingRead = {
...SERVER_ROW,
enabled:
typeof body.enabled === "boolean" ? body.enabled : SERVER_ROW.enabled,
cron_schedule:
typeof body.cron_schedule === "string" || body.cron_schedule === null
? (body.cron_schedule as string | null)
: SERVER_ROW.cron_schedule,
rate_ms:
typeof body.rate_ms === "number" || body.rate_ms === null
? (body.rate_ms as number | null)
: SERVER_ROW.rate_ms,
max_retries:
typeof body.max_retries === "number"
? body.max_retries
: SERVER_ROW.max_retries,
max_concurrency:
typeof body.max_concurrency === "number"
? body.max_concurrency
: SERVER_ROW.max_concurrency,
extra_config:
body.extra_config !== undefined
? (body.extra_config as Record<string, unknown>)
: SERVER_ROW.extra_config,
updated_at: "2026-06-13T12:00:00Z",
};
void path;
return Promise.resolve(updated);
}
return Promise.reject(new Error(`Unexpected fetch ${init.method} ${path}`));
},
);
vi.mock("@/lib/api", () => ({
apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init),
}));
function renderPanel() {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<JobSettingsPanel />
</QueryClientProvider>,
);
}
beforeEach(() => {
apiFetchMock.mockClear();
putBodies = [];
});
afterEach(() => {
vi.clearAllMocks();
});
describe("JobSettingsPanel — extra_config persistence (#1239)", () => {
it("отправляет правленый JSON extra_config даже если textarea свёрнута на момент save", async () => {
renderPanel();
// Ждём появления строки (загрузка settings).
await screen.findByText("noise_loader");
// 1. Раскрыть JSON textarea.
const toggle = screen.getByRole("button", { name: /JSON|скрыть/ });
await userEvent.click(toggle);
// 2. Найти textarea, очистить и ввести новый JSON.
const textarea = (await screen.findByDisplayValue(
/timeout_s/,
)) as HTMLTextAreaElement;
await userEvent.clear(textarea);
await userEvent.type(textarea, '{{"timeout_s": 99, "retries": 5}');
// 3. Свернуть textarea (главное условие бага — «▼ скрыть»).
await userEvent.click(screen.getByRole("button", { name: /скрыть/ }));
// 4. Save.
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
// 5. PUT должен содержать extra_config с новым значением — а НЕ
// отсутствовать (как было до фикса).
await waitFor(() => {
expect(putBodies).toHaveLength(1);
});
const sent = putBodies[0];
expect(sent).toHaveProperty("extra_config");
expect(sent.extra_config).toEqual({ timeout_s: 99, retries: 5 });
});
it("не шлёт extra_config, если JSON-текст не менялся (избегаем лишних writes)", async () => {
renderPanel();
await screen.findByText("noise_loader");
// Поменять только enabled (чекбокс), JSON не трогать.
const checkbox = screen.getByRole("checkbox");
await userEvent.click(checkbox);
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => {
expect(putBodies).toHaveLength(1);
});
expect(putBodies[0]).not.toHaveProperty("extra_config");
expect(putBodies[0]).toHaveProperty("enabled", false);
});
it("показывает ошибку и раскрывает строку, если JSON невалиден (даже при collapsed)", async () => {
renderPanel();
await screen.findByText("noise_loader");
// Раскрыть → испортить JSON → свернуть → save.
await userEvent.click(screen.getByRole("button", { name: /JSON|скрыть/ }));
const textarea = (await screen.findByDisplayValue(
/timeout_s/,
)) as HTMLTextAreaElement;
await userEvent.clear(textarea);
await userEvent.type(textarea, "{{not json");
await userEvent.click(screen.getByRole("button", { name: /скрыть/ }));
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
// Ошибка должна появиться, textarea — открыться.
expect(await screen.findByText(/Невалидный JSON/)).toBeInTheDocument();
// PUT НЕ должен был уйти.
expect(putBodies).toHaveLength(0);
});
});