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];
|
const draft = drafts[job_type];
|
||||||
if (!draft) return;
|
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;
|
let extra_config: Record<string, unknown> | undefined;
|
||||||
if (expanded[job_type]) {
|
if (extraConfigDirty) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(draft.extra_config_text) as unknown;
|
const parsed = JSON.parse(draft.extra_config_text) as unknown;
|
||||||
if (
|
if (
|
||||||
|
|
@ -157,6 +171,11 @@ export function JobSettingsPanel() {
|
||||||
patchDraft(job_type, {
|
patchDraft(job_type, {
|
||||||
extra_config_error: "Должен быть JSON-объект (не массив и не null)",
|
extra_config_error: "Должен быть JSON-объект (не массив и не null)",
|
||||||
});
|
});
|
||||||
|
// Раскрыть строку, иначе пользователь не увидит сообщение об ошибке.
|
||||||
|
setExpanded((prev: Record<string, boolean>) => ({
|
||||||
|
...prev,
|
||||||
|
[job_type]: true,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
extra_config = parsed as Record<string, unknown>;
|
extra_config = parsed as Record<string, unknown>;
|
||||||
|
|
@ -165,6 +184,10 @@ export function JobSettingsPanel() {
|
||||||
patchDraft(job_type, {
|
patchDraft(job_type, {
|
||||||
extra_config_error: `Невалидный JSON: ${(e as Error).message}`,
|
extra_config_error: `Невалидный JSON: ${(e as Error).message}`,
|
||||||
});
|
});
|
||||||
|
setExpanded((prev: Record<string, boolean>) => ({
|
||||||
|
...prev,
|
||||||
|
[job_type]: true,
|
||||||
|
}));
|
||||||
return;
|
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