The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to
|
|
// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep
|
|
// a draft only while the field is being edited. That matters here more than elsewhere
|
|
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
|
|
// thing a poll can overwrite at the wrong moment.
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, cleanup, act, waitFor, within } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
|
|
});
|
|
|
|
import Scheduler from "./Scheduler";
|
|
import { NavigationActionsContext } from "./navigationContext";
|
|
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
|
|
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
|
|
running_here: true,
|
|
enabled: true,
|
|
paused: false,
|
|
jobs: [
|
|
{
|
|
id: "sync",
|
|
interval_minutes: 30,
|
|
next_run: null,
|
|
running: false,
|
|
status: "ok",
|
|
last_started: null,
|
|
last_finished: null,
|
|
last_result: null,
|
|
result: null,
|
|
last_error: null,
|
|
last_trigger: null,
|
|
last_run_id: null,
|
|
quota_units: null,
|
|
recent: [],
|
|
progress: null,
|
|
},
|
|
],
|
|
ad_hoc: [],
|
|
queue: {
|
|
channels_recent_pending: 0,
|
|
channels_deep_pending: 0,
|
|
deep_eta_seconds: 0,
|
|
videos_pending_enrich: 0,
|
|
videos_pending_shorts: 0,
|
|
videos_live_refresh: 0,
|
|
},
|
|
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
|
|
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
|
|
...over,
|
|
});
|
|
|
|
let qc: QueryClient;
|
|
let current: SchedulerStatus;
|
|
|
|
async function mount(initial = status()) {
|
|
current = initial;
|
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<Scheduler />
|
|
</QueryClientProvider>,
|
|
);
|
|
await screen.findByText("scheduler.maintenance.batchLabel");
|
|
}
|
|
|
|
/** A poll landing, driven through the query the page actually makes. */
|
|
async function poll(next: SchedulerStatus) {
|
|
current = next;
|
|
await act(() => qc.refetchQueries({ queryKey: qk.scheduler() }));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const intervalBox = () => screen.queryByRole("spinbutton", { name: "" });
|
|
const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel");
|
|
|
|
// The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for the draft sweep: the old
|
|
// JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while
|
|
// editing, so each of those four passes against the pre-sweep code too. They are here because the
|
|
// editor had no tests at all. The two after them — the range refusal and the focus return — ARE
|
|
// regression guards, for the silent-discard fix, and they fail against the code this file shipped
|
|
// with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box
|
|
// below: its old effect had no guard, and that one has its own test.
|
|
describe("Scheduler · the job interval editor", () => {
|
|
it("opens on the current interval and commits a new one", async () => {
|
|
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
|
await mount();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
const box = intervalBox()!;
|
|
expect(box).toHaveProperty("value", "30");
|
|
|
|
fireEvent.change(box, { target: { value: "45" } });
|
|
fireEvent.keyDown(box, { key: "Enter" });
|
|
expect(intervalBox()).toBeNull(); // committing closes the editor at once
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45));
|
|
});
|
|
|
|
it("drops what you typed on Escape and shows the server's value again", async () => {
|
|
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
|
await mount();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
fireEvent.change(intervalBox()!, { target: { value: "999" } });
|
|
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
|
|
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(intervalBox()).toBeNull();
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
expect(intervalBox()).toHaveProperty("value", "30");
|
|
});
|
|
|
|
it("keeps what you are typing when a poll lands underneath", async () => {
|
|
await mount();
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
fireEvent.change(intervalBox()!, { target: { value: "45" } });
|
|
|
|
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] }));
|
|
|
|
expect(intervalBox()).toHaveProperty("value", "45");
|
|
});
|
|
|
|
it("follows the server while the editor is closed", async () => {
|
|
await mount();
|
|
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] }));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
expect(intervalBox()).toHaveProperty("value", "90");
|
|
});
|
|
|
|
it("refuses a value outside the range instead of silently dropping it", async () => {
|
|
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
|
|
await mount();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
fireEvent.change(intervalBox()!, { target: { value: "99999" } });
|
|
fireEvent.keyDown(intervalBox()!, { key: "Enter" });
|
|
|
|
expect(update).not.toHaveBeenCalled();
|
|
// Still open, still showing what was typed, and saying why.
|
|
expect(intervalBox()).toHaveProperty("value", "99999");
|
|
expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true");
|
|
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
|
|
});
|
|
|
|
it("hands focus back to the trigger when the editor closes", async () => {
|
|
await mount();
|
|
const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ });
|
|
fireEvent.click(trigger);
|
|
expect(document.activeElement).toBe(intervalBox());
|
|
|
|
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
|
|
await waitFor(() =>
|
|
expect(document.activeElement).toBe(
|
|
screen.getByRole("button", { name: /scheduler.everyMin/ }),
|
|
),
|
|
);
|
|
});
|
|
|
|
// A real Enter re-opened the editor — the keystroke's own activation landed on the trigger
|
|
// the focus had just returned to. The closing keys cancel their default action.
|
|
it("closing by a key does not press the trigger again", async () => {
|
|
await mount();
|
|
for (const key of ["Enter", "Escape"]) {
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
expect(fireEvent.keyDown(intervalBox()!, { key })).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Scheduler · the maintenance batch editor", () => {
|
|
it("keeps what you are typing when a poll changes the batch underneath", async () => {
|
|
// THE regression this editor needed: its old effect re-synced on every
|
|
// `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced
|
|
// what the admin had typed.
|
|
await mount();
|
|
fireEvent.change(batchBox(), { target: { value: "9000" } });
|
|
|
|
// The poll carries a new interval TOO, and the interval label follows the server unconditionally
|
|
// — so waiting for it is how this test knows the new status has actually been rendered. Without
|
|
// that anchor the assertion below passes on a page that simply has not polled yet.
|
|
await poll(
|
|
status({
|
|
jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }],
|
|
maintenance: { ...status().maintenance, revalidate_batch: 12000 },
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
|
|
);
|
|
|
|
expect(batchBox()).toHaveProperty("value", "9000");
|
|
});
|
|
|
|
it("refuses a value outside min..max instead of silently dropping it", async () => {
|
|
const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({
|
|
revalidate_batch: 5000,
|
|
});
|
|
await mount();
|
|
|
|
fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000
|
|
fireEvent.keyDown(batchBox(), { key: "Enter" });
|
|
|
|
expect(save).not.toHaveBeenCalled();
|
|
expect(batchBox()).toHaveProperty("value", "10");
|
|
expect(batchBox().getAttribute("aria-invalid")).toBe("true");
|
|
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
|
|
expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true);
|
|
});
|
|
|
|
it("is not dirty until edited, and stops being dirty once saved", async () => {
|
|
const save = vi
|
|
.spyOn(api, "updateMaintenanceBatch")
|
|
.mockResolvedValue({ revalidate_batch: 9000 });
|
|
await mount();
|
|
const saveBtn = () => screen.getByRole("button", { name: "common.save" });
|
|
|
|
expect(saveBtn()).toHaveProperty("disabled", true);
|
|
fireEvent.change(batchBox(), { target: { value: "9000" } });
|
|
expect(saveBtn()).toHaveProperty("disabled", false);
|
|
|
|
fireEvent.keyDown(batchBox(), { key: "Enter" });
|
|
expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once
|
|
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
|
|
});
|
|
|
|
it("follows a poll that changes the batch while nothing is being typed", async () => {
|
|
await mount();
|
|
expect(batchBox()).toHaveProperty("value", "5000");
|
|
await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } }));
|
|
await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000"));
|
|
});
|
|
});
|
|
|
|
describe("Scheduler while paused", () => {
|
|
// Unavailable WITH a reason ("resume first"): the runs stay in the tab order, so a keyboard user
|
|
// reaches the hint, and they are inert. Natively disabled, the reason was mouse-only.
|
|
it("keeps Run all and a job's Run focusable and inert", async () => {
|
|
const runAll = vi.spyOn(api, "runAllSchedulerJobs").mockResolvedValue({ started: [] });
|
|
const runOne = vi.spyOn(api, "runSchedulerJob").mockResolvedValue({ started: true } as never);
|
|
await mount(status({ paused: true }));
|
|
for (const name of ["scheduler.runAll", "scheduler.runNow"]) {
|
|
const btn = screen.getByRole("button", { name });
|
|
expect(btn.hasAttribute("disabled")).toBe(false);
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
fireEvent.click(btn);
|
|
}
|
|
expect(runAll).not.toHaveBeenCalled();
|
|
expect(runOne).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("a job's last result", () => {
|
|
it("is named from its data, not the server's English one-liner", async () => {
|
|
const base = status();
|
|
await mount({
|
|
...base,
|
|
jobs: [
|
|
{
|
|
...base.jobs[0]!,
|
|
last_finished: new Date().toISOString(),
|
|
last_result: "new=3, failed=0",
|
|
result: { new: 3, failed: 0 },
|
|
},
|
|
],
|
|
});
|
|
// Key-echo i18n: the keys named, the counts shown.
|
|
expect(
|
|
await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText(/new=3/)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("a row's run history", () => {
|
|
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
|
const run = (id: number, status: RunBrief["status"], minutesAgo: number): RunBrief => ({
|
|
id,
|
|
status,
|
|
trigger: "scheduled",
|
|
started_at: at(minutesAgo),
|
|
finished_at: status === "running" ? null : at(minutesAgo),
|
|
});
|
|
const dots = (root: HTMLElement = document.body) =>
|
|
within(root)
|
|
.getAllByRole("img")
|
|
.map((d) => d.getAttribute("data-dot"));
|
|
|
|
// An interrupted run fell through to "idle" — a grey dot that said no run was ever recorded.
|
|
it("shows the last runs newest first, an interrupted one as such", async () => {
|
|
const base = status();
|
|
await mount({
|
|
...base,
|
|
jobs: [
|
|
{
|
|
...base.jobs[0]!,
|
|
status: "interrupted",
|
|
last_finished: at(2),
|
|
recent: [run(3, "interrupted", 2), run(2, "error", 12), run(1, "ok", 22)],
|
|
},
|
|
],
|
|
});
|
|
expect(dots()).toEqual(["interrupted", "error", "ok"]);
|
|
expect(screen.getByText(/scheduler\.interrupted/)).toBeTruthy();
|
|
expect(screen.getByText("scheduler.dot.interrupted")).toBeTruthy(); // the legend names it
|
|
});
|
|
|
|
it("shows one idle dot for a job that never ran", async () => {
|
|
await mount();
|
|
expect(dots()).toEqual(["idle"]);
|
|
});
|
|
|
|
it("gives a run the job has claimed, but not yet recorded, the newest dot", async () => {
|
|
const base = status();
|
|
await mount({
|
|
...base,
|
|
jobs: [
|
|
{
|
|
...base.jobs[0]!,
|
|
running: true,
|
|
recent: [run(3, "ok", 10), run(2, "ok", 20), run(1, "error", 30)],
|
|
},
|
|
],
|
|
});
|
|
expect(dots()).toEqual(["running", "ok", "ok"]);
|
|
});
|
|
|
|
// The one-channel full-history backfill ran unseen: a background task only the log heard of.
|
|
it("lists a user's ad-hoc run beside the jobs, with its channel and no run button", async () => {
|
|
const base = status();
|
|
const adHoc: SchedulerAdHoc = {
|
|
...base.jobs[0]!,
|
|
id: "channel_backfill",
|
|
running: false,
|
|
target: "UC_bbc",
|
|
target_title: "BBC Earth",
|
|
last_finished: at(60),
|
|
result: { videos_added: 69 },
|
|
recent: [run(9, "ok", 60), run(8, "ok", 120)],
|
|
};
|
|
await mount({ ...base, ad_hoc: [adHoc] });
|
|
expect(screen.getByText("scheduler.adHocTitle")).toBeTruthy();
|
|
const row = screen.getByText("scheduler.jobs.channel_backfill").closest<HTMLElement>(".p-3")!;
|
|
expect(dots(row)).toEqual(["ok", "ok"]);
|
|
expect(within(row).getByText(/BBC Earth/)).toBeTruthy();
|
|
expect(within(row).getByText(/scheduler\.result\.videos_added: 69/)).toBeTruthy();
|
|
expect(within(row).getByText("scheduler.adHocStarted")).toBeTruthy();
|
|
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("a row's history", () => {
|
|
// The dots show three runs; the rest is the activity page's run list, narrowed to the row's job.
|
|
it("opens the activity page's runs of that job", async () => {
|
|
const setPage = vi.fn((_page: string, then?: () => void) => then?.());
|
|
window.history.replaceState(null, "", "/?page=scheduler");
|
|
current = status();
|
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<NavigationActionsContext.Provider
|
|
value={{
|
|
setPage,
|
|
openChannel: () => {},
|
|
closeChannel: () => {},
|
|
enterYtSearch: () => {},
|
|
exitYtSearch: () => {},
|
|
}}
|
|
>
|
|
<QueryClientProvider client={qc}>
|
|
<Scheduler />
|
|
</QueryClientProvider>
|
|
</NavigationActionsContext.Provider>,
|
|
);
|
|
await screen.findByText("scheduler.maintenance.batchLabel");
|
|
const job = current.jobs[0]!.id;
|
|
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".p-3")!;
|
|
|
|
fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" }));
|
|
|
|
expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function));
|
|
const url = new URLSearchParams(window.location.search);
|
|
expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]);
|
|
});
|
|
});
|