fix: fail closed on corrupt mute schedules and clean invalid replicas

Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
This commit is contained in:
SaelixCode
2026-07-21 11:47:56 -04:00
parent 48a2662f5e
commit 1d1fba0e33
7 changed files with 303 additions and 29 deletions
@@ -583,6 +583,8 @@ export function NotificationSuppressionSection({
size="sm"
variant={selected ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
aria-pressed={selected}
aria-label={label}
onClick={() => {
setFormScheduleDays((prev) =>
selected
@@ -598,12 +600,22 @@ export function NotificationSuppressionSection({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Start (UTC)</Label>
<Input type="time" value={formScheduleStart} onChange={(e) => setFormScheduleStart(e.target.value)} />
<Label htmlFor="mute-schedule-start" className="text-xs text-muted-foreground">Start (UTC)</Label>
<Input
id="mute-schedule-start"
type="time"
value={formScheduleStart}
onChange={(e) => setFormScheduleStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">End (UTC)</Label>
<Input type="time" value={formScheduleEnd} onChange={(e) => setFormScheduleEnd(e.target.value)} />
<Label htmlFor="mute-schedule-end" className="text-xs text-muted-foreground">End (UTC)</Label>
<Input
id="mute-schedule-end"
type="time"
value={formScheduleEnd}
onChange={(e) => setFormScheduleEnd(e.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
@@ -1,8 +1,8 @@
/**
* NotificationSuppressionSection stack pattern chips.
* NotificationSuppressionSection stack pattern chips and weekly schedule UI.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
@@ -38,10 +38,49 @@ vi.mock('@/lib/muteRules', () => ({
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { NotificationSuppressionSection } from '../NotificationSuppressionSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const scheduledRule = {
id: 42,
name: 'Weekend mute',
node_id: null,
stack_patterns: ['prod-*'],
label_ids: null,
categories: null,
levels: null,
applies_to: 'both',
enabled: true,
expires_at: null,
schedule: {
days: [6],
start_minute: 120,
end_minute: 360,
tz: 'UTC',
},
created_at: 1,
updated_at: 1,
};
function mockListRules(rules: unknown[] = []) {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-suppression-rules' && !opts?.method) {
return { ok: true, json: async () => rules };
}
if (url === '/stacks') return { ok: true, json: async () => ['staging'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-suppression-rules' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 1 }) };
}
if (typeof url === 'string' && url.startsWith('/notification-suppression-rules/') && opts?.method === 'PUT') {
return { ok: true, json: async () => ({ id: 42 }) };
}
return { ok: true, json: async () => ([]) };
});
}
async function openMuteForm() {
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument());
@@ -49,20 +88,16 @@ async function openMuteForm() {
await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).toBeInTheDocument());
}
async function enableWeeklyWindow() {
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
await waitFor(() => expect(screen.getByRole('button', { name: 'Sat' })).toBeInTheDocument());
}
describe('NotificationSuppressionSection', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-suppression-rules' && !opts?.method) {
return { ok: true, json: async () => [] };
}
if (url === '/stacks') return { ok: true, json: async () => ['staging'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-suppression-rules' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 1 }) };
}
return { ok: true, json: async () => ([]) };
});
vi.mocked(toast.error).mockClear();
mockListRules([]);
});
it('posts normalized stack patterns and null levels', async () => {
@@ -129,4 +164,109 @@ describe('NotificationSuppressionSection', () => {
expect(posts).toHaveLength(0);
});
});
it('posts a weekly UTC schedule with accessible day and time controls', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Sat window');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
await enableWeeklyWindow();
const sat = screen.getByRole('button', { name: 'Sat' });
expect(sat).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(sat);
expect(sat).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Start (UTC)')).toHaveAttribute('id', 'mute-schedule-start');
expect(screen.getByLabelText('End (UTC)')).toHaveAttribute('id', 'mute-schedule-end');
fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } });
fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } });
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
);
expect(post).toBeTruthy();
const body = JSON.parse((post![1] as { body: string }).body);
expect(body.schedule).toEqual({
days: [6],
start_minute: 120,
end_minute: 360,
tz: 'UTC',
});
});
});
it('blocks scheduled create when no weekday is selected', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'No days');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
await enableWeeklyWindow();
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Select at least one day for the weekly window.');
});
const posts = mockedFetch.mock.calls.filter(
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
);
expect(posts).toHaveLength(0);
});
it('hydrates edit form from an existing schedule and keeps it on PUT', async () => {
mockListRules([scheduledRule]);
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument());
expect(screen.getByText(/UTC Sat 02:00-06:00/)).toBeInTheDocument();
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('button', { name: 'Sat' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Start (UTC)')).toHaveValue('02:00');
expect(screen.getByLabelText('End (UTC)')).toHaveValue('06:00');
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const put = mockedFetch.mock.calls.find(
([url, opts]) =>
url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT',
);
expect(put).toBeTruthy();
const body = JSON.parse((put![1] as { body: string }).body);
expect(body.schedule).toEqual({
days: [6],
start_minute: 120,
end_minute: 360,
tz: 'UTC',
});
});
});
it('clears schedule to null when weekly window is turned off on edit', async () => {
mockListRules([scheduledRule]);
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument());
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Sat' })).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const put = mockedFetch.mock.calls.find(
([url, opts]) =>
url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT',
);
expect(put).toBeTruthy();
const body = JSON.parse((put![1] as { body: string }).body);
expect(body.schedule).toBeNull();
});
});
});