From 1e2fa35c8ee98521bc1133e8d53e67d7d2558e74 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 13:05:08 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20persist=20JSON=20extra=5Fconfig?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=B2=D1=91=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20textarea=20(#1239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/admin/JobSettingsPanel.tsx | 25 ++- .../admin/__tests__/JobSettingsPanel.test.tsx | 200 ++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/__tests__/JobSettingsPanel.test.tsx diff --git a/frontend/src/components/admin/JobSettingsPanel.tsx b/frontend/src/components/admin/JobSettingsPanel.tsx index c8d639a4..a6e0b427 100644 --- a/frontend/src/components/admin/JobSettingsPanel.tsx +++ b/frontend/src/components/admin/JobSettingsPanel.tsx @@ -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 | 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) => ({ + ...prev, + [job_type]: true, + })); return; } extra_config = parsed as Record; @@ -165,6 +184,10 @@ export function JobSettingsPanel() { patchDraft(job_type, { extra_config_error: `Невалидный JSON: ${(e as Error).message}`, }); + setExpanded((prev: Record) => ({ + ...prev, + [job_type]: true, + })); return; } } diff --git a/frontend/src/components/admin/__tests__/JobSettingsPanel.test.tsx b/frontend/src/components/admin/__tests__/JobSettingsPanel.test.tsx new file mode 100644 index 00000000..f21ee9cf --- /dev/null +++ b/frontend/src/components/admin/__tests__/JobSettingsPanel.test.tsx @@ -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; + 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> = []; + +const apiFetchMock = vi.fn( + (path: string, init?: RequestInit): Promise => { + 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; + 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) + : 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( + + + , + ); +} + +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); + }); +});