From 9f28c69709cca6d76a5a3dbe19c29fe1a60d3bb7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:19:51 +0200 Subject: [PATCH] =?UTF-8?q?test(ui/alerts):=20InboxPage=20=E2=80=94=20filt?= =?UTF-8?q?er=20defaults,=20toggle=20behavior,=20role-gated=20delete,=20un?= =?UTF-8?q?do=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/pages/Alerts/InboxPage.test.tsx | 209 +++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 ui/src/pages/Alerts/InboxPage.test.tsx diff --git a/ui/src/pages/Alerts/InboxPage.test.tsx b/ui/src/pages/Alerts/InboxPage.test.tsx new file mode 100644 index 00000000..f9a5244a --- /dev/null +++ b/ui/src/pages/Alerts/InboxPage.test.tsx @@ -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( + + + + + + + + + , + ); +} + +// ── 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(); + }); +});