Files
sencho/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx
T
Anso 7320a86579 feat: add cron scheduling mode for image update checks (#1460)
* feat: add cron scheduling mode for image update checks

Adds a cron scheduling mode alongside the existing fixed-interval
dropdown in Settings > Automation > Image update checks. Users can
now set a 5-field cron expression (e.g. "0 3 * * 1") for precise
time-of-day scheduling of registry polls.

- Backend: ImageUpdateService gains mode/cronExpression fields and
  cron-based nextDelayMs() using the existing cron-parser dependency.
  PUT /api/image-updates/interval extended with transactional writes
  and server-authoritative cron validation matching the Scheduled
  Operations contract. Nicknames like @daily are supported.
- Frontend: UpdatesSection gains a SegmentedControl toggle and cron
  text input with cronstrue-powered live description. The frontend
  does advisory validation only; backend 400s are surfaced inline.
  SettingsPrimaryButton used for explicit "Save schedule" action.
- No cron jitter (the user chose a specific time). Interval mode
  keeps existing ±10% jitter.
- Tests: 15 new backend tests covering valid cron, invalid cron,
  6-field rejection, nickname support, backward compat, runtime
  fallback, and transactional writes.
- Docs: auto-update-policies.mdx, alerts-notifications.mdx, and
  openapi.yaml updated with new scheduling mode.

* fix: add mode and cronExpression to UpdatesSection test fixtures

The existing tests failed because the mock status object was missing
the new required fields (mode, cronExpression) added with cron
scheduling support. Without them, status.mode was undefined, causing
uiMode to never match 'interval' and the Select combobox to not render.

* fix: prevent SegmentedControl from stretching full-width in SettingsField

The flex-col container defaults items to align-self: stretch, making the
Interval/Cron toggle bar span the full card width. Add self-start so it
sizes to its content.
2026-06-25 19:47:57 -04:00

261 lines
9.7 KiB
TypeScript

/**
* MobileReadinessCard is the one-up phone card for the Updates readiness board.
* Its Apply button is disabled only when the update is blocked (major bump) or
* already in flight; manual apply works regardless of schedule. The Auto: Off
* pill still reflects the absence of a covering auto-update schedule.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }),
}));
import { apiFetch, fetchForNode } from '@/lib/api';
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
function card(over: Partial<StackCard> = {}): StackCard {
return {
stack: 'nextcloud',
nodeId: 1,
previewLoaded: true,
applying: false,
autoUpdateEnabled: true,
scheduledTask: null,
preview: {
stack_name: 'nextcloud',
images: [],
summary: {
has_update: true,
primary_image: 'nextcloud',
current_tag: '27.1.4',
next_tag: '27.1.5',
semver_bump: 'patch',
update_kind: 'tag',
blocked: false,
blocked_reason: null,
},
rollback_target: null,
changelog: 'Fixes. Security patch.',
},
...over,
};
}
const apply = () => screen.getByRole('button', { name: /Apply now/i });
it('enables Apply for a safe, non-blocked update', () => {
render(<MobileReadinessCard card={card()} onApply={vi.fn()} />);
expect(apply()).toBeEnabled();
});
it('disables Apply when the update is blocked (major bump)', () => {
render(
<MobileReadinessCard
card={card({
preview: {
stack_name: 'gitea', images: [], rollback_target: null, changelog: 'Breaking.',
summary: {
has_update: true, primary_image: 'gitea', current_tag: '1.21', next_tag: '1.22',
semver_bump: 'major', update_kind: 'tag', blocked: true, blocked_reason: 'Major version bump',
},
},
})}
onApply={vi.fn()}
/>,
);
expect(apply()).toBeDisabled();
});
it('disables Apply while an update is in flight', () => {
render(<MobileReadinessCard card={card({ applying: true })} onApply={vi.fn()} />);
// While applying the button label switches to "Applying...".
expect(screen.getByRole('button', { name: /Applying/i })).toBeDisabled();
});
it('enables Apply when no schedule covers the stack', () => {
render(<MobileReadinessCard card={card({ autoUpdateEnabled: false })} onApply={vi.fn()} />);
expect(apply()).toBeEnabled();
});
/**
* The desktop StackReadinessCard is not exported, so its Apply-now gating is
* covered through a full-view render (useIsMobile is mocked false). A safe
* update with no covering schedule must still offer an enabled Apply now: the
* button is manual and schedule-independent, while the Auto: Off pill keeps
* reflecting the missing schedule.
*/
describe('AutoUpdateReadinessView desktop Apply now', () => {
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
afterEach(() => {
mockedFetch.mockReset();
mockedFetchForNode.mockReset();
});
it('enables Apply for a safe update with no covering schedule', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/image-updates/fleet') {
return Promise.resolve({ ok: true, json: async () => ({ '1': { nextcloud: true } }) });
}
if (url.startsWith('/scheduled-tasks')) {
return Promise.resolve({ ok: true, json: async () => [] });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
mockedFetchForNode.mockResolvedValue({ ok: true, json: async () => card().preview });
render(<AutoUpdateReadinessView />);
const applyBtn = await screen.findByRole('button', { name: /Apply now/i });
expect(applyBtn).toBeEnabled();
// A non-blocked card carries no title at all; the old schedule tooltip is gone.
expect(applyBtn).not.toHaveAttribute('title');
expect(screen.getByText(/Auto: Off/)).toBeInTheDocument();
// The stack is enabled to apply manually but must NOT count as "ready to
// apply automatically": that still requires a covering schedule.
expect(screen.getByText(/0 of 1 ready to apply automatically/)).toBeInTheDocument();
});
});
/**
* CadenceStrip surfaces the control instance's detection cadence by the
* readiness card: a past last-check must read as an "ago" value (not the
* future-oriented "due now"), null timestamps read as never/not-scheduled, and
* the manual-recheck cooldown ticks down to "Recheck ready".
*/
describe('CadenceStrip', () => {
afterEach(() => {
vi.useRealTimers();
});
it('renders a past last-check as an "ago" value, not "due now"', () => {
const cadence = {
checking: false,
intervalMinutes: 120,
lastCheckedAt: Date.now() - 10 * 60 * 1000,
nextCheckAt: Date.now() + 110 * 60 * 1000,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 0,
mode: 'interval' as const,
cronExpression: null,
};
render(<CadenceStrip cadence={cadence} />);
expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument();
expect(screen.queryByText(/due now/)).not.toBeInTheDocument();
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
});
it('renders null timestamps as never / not scheduled', () => {
const cadence = {
checking: false,
intervalMinutes: 120,
lastCheckedAt: null,
nextCheckAt: null,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 0,
mode: 'interval' as const,
cronExpression: null,
};
render(<CadenceStrip cadence={cadence} />);
expect(screen.getByText(/Last checked never/)).toBeInTheDocument();
expect(screen.getByText(/Next check not scheduled/)).toBeInTheDocument();
});
it('counts the manual-recheck cooldown down to "Recheck ready"', () => {
vi.useFakeTimers();
const cadence = {
checking: false,
intervalMinutes: 120,
lastCheckedAt: Date.now(),
nextCheckAt: Date.now() + 7_200_000,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 3000,
mode: 'interval' as const,
cronExpression: null,
};
render(<CadenceStrip cadence={cadence} />);
expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(3000); });
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
});
});
/**
* The cadence fetch runs on mount AND after a Recheck. A slow initial /status
* response that resolves after the recheck-triggered one must not overwrite the
* fresh cooldown the recheck just loaded.
*/
describe('AutoUpdateReadinessView cadence fetch race', () => {
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
afterEach(() => {
vi.clearAllMocks();
});
function statusDeferred() {
let resolveWith!: (manualCooldownRemainingMs: number) => void;
const promise = new Promise<{ ok: true; json: () => Promise<unknown> }>((resolve) => {
resolveWith = (manualCooldownRemainingMs: number) =>
resolve({
ok: true,
json: async () => ({
checking: false,
intervalMinutes: 120,
lastCheckedAt: Date.now() - 60_000,
nextCheckAt: Date.now() + 3_600_000,
manualCooldownMinutes: 2,
manualCooldownRemainingMs,
}),
});
});
return { promise, resolveWith };
}
it('drops a stale /status response so a recheck cooldown is not overwritten', async () => {
const statusCalls: ReturnType<typeof statusDeferred>[] = [];
mockedFetch.mockImplementation((url: string) => {
if (url === '/image-updates/fleet') return Promise.resolve({ ok: true, json: async () => ({}) });
if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] });
if (url === '/image-updates/fleet/refresh') {
return Promise.resolve({ ok: true, json: async () => ({ triggered: [1], rateLimited: [], failed: [] }) });
}
if (url === '/image-updates/status') {
const d = statusDeferred();
statusCalls.push(d);
return d.promise;
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
render(<AutoUpdateReadinessView />);
// Mount fired the first /status (A); it stays pending. The hero renders once
// the readiness load settles.
const recheck = await screen.findByRole('button', { name: /recheck registries/i });
expect(statusCalls).toHaveLength(1);
// Recheck fires a second /status (B); resolve it with an active cooldown.
await act(async () => { fireEvent.click(recheck); });
await waitFor(() => expect(statusCalls).toHaveLength(2));
await act(async () => { statusCalls[1].resolveWith(120_000); });
await screen.findByText(/Recheck available in/);
// The slow initial load (A) resolves last with no cooldown. The token guard
// must drop it so the strip keeps showing the recheck cooldown.
await act(async () => {
statusCalls[0].resolveWith(0);
await Promise.resolve();
});
expect(screen.queryByText(/Recheck ready/)).toBeNull();
expect(screen.getByText(/Recheck available in/)).toBeInTheDocument();
});
});