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