fix(admin): persist JSON extra_config при свёрнутой textarea (#1239)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
This commit is contained in:
parent
816bb3d3bd
commit
1e2fa35c8e
2 changed files with 224 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue