test(ui/alerts): InboxPage — filter defaults, toggle behavior, role-gated delete, undo toast
Covers: default useAlerts call (FIRING + hide-acked + hide-read), Hide-acked toggle removes the acked filter, Acknowledge button only renders for unacked rows, bulk-delete confirmation dialog with count, delete buttons hidden for non-OPERATOR users, row-delete wires to useDeleteAlert + renders an Undo action. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal file
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { ThemeProvider, ToastProvider } from '@cameleer/design-system';
|
||||
import InboxPage from './InboxPage';
|
||||
import type { AlertDto } from '../../api/queries/alerts';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useEnvironmentStore } from '../../api/environment-store';
|
||||
|
||||
// ── hook mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Capture the args passed to useAlerts so we can assert on filter params.
|
||||
const alertsCallArgs = vi.fn();
|
||||
const deleteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const bulkDeleteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const ackMock = vi.fn().mockResolvedValue(undefined);
|
||||
const markReadMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
const markReadMutate = vi.fn();
|
||||
const bulkAckMock = vi.fn().mockResolvedValue(undefined);
|
||||
const bulkReadMock = vi.fn().mockResolvedValue(undefined);
|
||||
const restoreMock = vi.fn().mockResolvedValue(undefined);
|
||||
const createSilenceMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// alertsMock is a factory — each call records its args and returns the current
|
||||
// rows so we can change the fixture per test.
|
||||
let currentRows: AlertDto[] = [];
|
||||
|
||||
vi.mock('../../api/queries/alerts', () => ({
|
||||
useAlerts: (filter: unknown) => {
|
||||
alertsCallArgs(filter);
|
||||
return { data: currentRows, isLoading: false, error: null };
|
||||
},
|
||||
useAckAlert: () => ({ mutateAsync: ackMock, isPending: false }),
|
||||
useMarkAlertRead: () => ({ mutateAsync: markReadMutateAsync, mutate: markReadMutate, isPending: false }),
|
||||
useBulkReadAlerts: () => ({ mutateAsync: bulkReadMock, isPending: false }),
|
||||
useBulkAckAlerts: () => ({ mutateAsync: bulkAckMock, isPending: false }),
|
||||
useDeleteAlert: () => ({ mutateAsync: deleteMock, isPending: false }),
|
||||
useBulkDeleteAlerts: () => ({ mutateAsync: bulkDeleteMock, isPending: false }),
|
||||
useRestoreAlert: () => ({ mutateAsync: restoreMock }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/queries/alertSilences', () => ({
|
||||
useCreateSilence: () => ({ mutateAsync: createSilenceMock, isPending: false }),
|
||||
}));
|
||||
|
||||
// ── fixture rows ────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW_FIRING: AlertDto = {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
ruleId: 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr',
|
||||
state: 'FIRING',
|
||||
severity: 'CRITICAL',
|
||||
title: 'Order pipeline down',
|
||||
message: 'msg',
|
||||
firedAt: '2026-04-21T10:00:00Z',
|
||||
ackedAt: undefined,
|
||||
ackedBy: undefined,
|
||||
resolvedAt: undefined,
|
||||
readAt: undefined,
|
||||
silenced: false,
|
||||
currentValue: undefined,
|
||||
threshold: undefined,
|
||||
environmentId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
|
||||
context: {},
|
||||
};
|
||||
|
||||
const ROW_ACKED: AlertDto = {
|
||||
...ROW_FIRING,
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
ackedAt: '2026-04-21T10:05:00Z',
|
||||
ackedBy: 'alice',
|
||||
};
|
||||
|
||||
// ── mount helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/alerts/inbox']}>
|
||||
<InboxPage />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ── setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mocks to their default resolved values after clearAllMocks
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
bulkDeleteMock.mockResolvedValue(undefined);
|
||||
ackMock.mockResolvedValue(undefined);
|
||||
markReadMutateAsync.mockResolvedValue(undefined);
|
||||
bulkAckMock.mockResolvedValue(undefined);
|
||||
bulkReadMock.mockResolvedValue(undefined);
|
||||
restoreMock.mockResolvedValue(undefined);
|
||||
createSilenceMock.mockResolvedValue(undefined);
|
||||
|
||||
currentRows = [ROW_FIRING];
|
||||
|
||||
// Set OPERATOR role and an env so hooks are enabled
|
||||
useAuthStore.setState({ roles: ['OPERATOR'] });
|
||||
useEnvironmentStore.setState({ environment: 'dev' });
|
||||
});
|
||||
|
||||
// ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('InboxPage', () => {
|
||||
it('calls useAlerts with default filters: state=[FIRING], acked=false, read=false', () => {
|
||||
mount();
|
||||
|
||||
// useAlerts is called during render; check the first call's filter arg
|
||||
expect(alertsCallArgs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: ['FIRING'],
|
||||
acked: false,
|
||||
read: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unchecking "Hide acked" removes the acked filter', () => {
|
||||
mount();
|
||||
|
||||
// Initial call should include acked: false
|
||||
expect(alertsCallArgs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ acked: false }),
|
||||
);
|
||||
|
||||
// Find and uncheck the "Hide acked" toggle
|
||||
const hideAckedToggle = screen.getByRole('checkbox', { name: /hide acked/i });
|
||||
fireEvent.click(hideAckedToggle);
|
||||
|
||||
// After unchecking, useAlerts should be called without acked: false
|
||||
// (the component passes `undefined` when the toggle is off)
|
||||
const lastCall = alertsCallArgs.mock.calls[alertsCallArgs.mock.calls.length - 1][0];
|
||||
expect(lastCall.acked).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows Acknowledge button only on rows where ackedAt is null', () => {
|
||||
currentRows = [ROW_FIRING, ROW_ACKED];
|
||||
mount();
|
||||
|
||||
// ROW_FIRING has ackedAt=undefined → Ack button should appear
|
||||
// ROW_ACKED has ackedAt set → Ack button should NOT appear
|
||||
// The row-level Ack button label is "Ack"
|
||||
const ackButtons = screen.getAllByRole('button', { name: /^ack$/i });
|
||||
expect(ackButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens bulk-delete confirmation with the correct count', async () => {
|
||||
currentRows = [ROW_FIRING, ROW_ACKED];
|
||||
mount();
|
||||
|
||||
// Use the "Select all" checkbox in the filter bar to select all 2 rows.
|
||||
// It is labelled "Select all (2)" when nothing is selected.
|
||||
const selectAllCb = screen.getByRole('checkbox', { name: /select all/i });
|
||||
// fireEvent.click toggles checkboxes and triggers React's onChange
|
||||
fireEvent.click(selectAllCb);
|
||||
|
||||
// After selection the bulk toolbar should show a "Delete N" button
|
||||
const deleteButton = await screen.findByRole('button', { name: /^delete 2$/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// The ConfirmDialog should now be open with the count in the message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/delete 2 alerts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides Delete buttons when user lacks OPERATOR role', () => {
|
||||
useAuthStore.setState({ roles: ['VIEWER'] });
|
||||
mount();
|
||||
|
||||
// Neither the row-level "Delete alert" button nor the bulk "Delete N" button should appear
|
||||
expect(screen.queryByRole('button', { name: /delete alert/i })).toBeNull();
|
||||
// No selection is active so "Delete N" wouldn't appear anyway, but confirm
|
||||
// there's also no element with "Delete" that would open the confirm dialog
|
||||
const deleteButtons = screen
|
||||
.queryAllByRole('button')
|
||||
.filter((btn) => /^delete\b/i.test(btn.textContent ?? ''));
|
||||
expect(deleteButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clicking row Delete invokes useDeleteAlert and shows an Undo toast', async () => {
|
||||
mount();
|
||||
|
||||
const deleteAlertButton = screen.getByRole('button', { name: /delete alert/i });
|
||||
fireEvent.click(deleteAlertButton);
|
||||
|
||||
// Verify deleteMock was called with the row's id
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith(ROW_FIRING.id);
|
||||
});
|
||||
|
||||
// After deletion a toast appears with "Deleted" title and an "Undo" button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user