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:
hsiegeln
2026-04-21 19:19:51 +02:00
parent b20f08b3d0
commit 9f28c69709

View 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();
});
});