feat(outbox): UI fuer Event-Outbox — Modul 9/16 des UI-Backlogs

This commit is contained in:
Agent Zero
2026-09-13 23:13:58 +02:00
parent 00bfcafb9c
commit 31154b9dc6
7 changed files with 863 additions and 3 deletions
@@ -0,0 +1,180 @@
/**
* Outbox page tests — transactional outbox monitoring UI (admin).
*
* Covers: admin gate, stats cards, DLQ list with replay single/all,
* maintenance modals (recover-stuck, cleanup-published), consumer registry.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { OutboxPage } from '@/pages/Outbox';
import type { OutboxStats, FailedOutboxEvent, FailedEventsResponse } from '@/api/outbox';
const replayMut = vi.fn().mockResolvedValue({ replayed: true });
const replayAllMut = vi.fn().mockResolvedValue({ replayed_count: 2 });
const recoverMut = vi.fn().mockResolvedValue({ recovered_count: 1 });
const cleanupMut = vi.fn().mockResolvedValue({ deleted_count: 3 });
const makeEvent = (overrides: Partial<FailedOutboxEvent> = {}): FailedOutboxEvent => ({
id: '11111111-1111-1111-1111-111111111111',
tenant_id: 't-1',
event_name: 'contact.created',
error_message: 'handler crashed',
failed_at: '2026-09-13T10:00:00Z',
attempts: 3,
created_at: '2026-09-13T09:00:00Z',
status: 'failed',
...overrides,
});
let mockStats: OutboxStats | null = {
counts: { pending: 2, processing: 0, published: 40, failed: 1, no_handlers: 0 },
total: 43,
oldest_pending_age_seconds: 90,
};
let mockEvents: FailedOutboxEvent[] = [];
let mockRegistry: Record<string, string[]> = {};
let mockIsAdmin = true;
vi.mock('@/api/outbox', () => ({
useOutboxStats: () => ({
data: mockStats,
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
}),
useOutboxFailedEvents: () => ({
data: { events: mockEvents, limit: 20, offset: 0, count: mockEvents.length } as FailedEventsResponse,
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
}),
useOutboxConsumerRegistry: () => ({
data: { registry: mockRegistry },
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
}),
useReplayFailedEvent: () => ({ mutate: replayMut, isPending: false }),
useReplayAllFailedEvents: () => ({ mutate: replayAllMut, isPending: false }),
useRecoverStuckEvents: () => ({ mutate: recoverMut, isPending: false }),
useCleanupPublishedEvents: () => ({ mutate: cleanupMut, isPending: false }),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (state: { user: { is_system_admin: boolean } | null }) => unknown) =>
selector({ user: { is_system_admin: mockIsAdmin } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<OutboxPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockStats = {
counts: { pending: 2, processing: 0, published: 40, failed: 1, no_handlers: 0 },
total: 43,
oldest_pending_age_seconds: 90,
};
mockEvents = [];
mockRegistry = {};
mockIsAdmin = true;
});
describe('OutboxPage', () => {
it('renders page title and stats cards', () => {
renderPage();
expect(screen.getByTestId('outbox-page')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-pending')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-processing')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-published')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-failed')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-no_handlers')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-total')).toBeInTheDocument();
expect(screen.getByTestId('outbox-stat-total')).toHaveTextContent('43');
});
it('renders oldest pending age when present', () => {
renderPage();
// formatAge(90) → '2min' (90 seconds rounds to minutes)
expect(screen.getByTestId('outbox-oldest-pending')).toHaveTextContent('2min');
});
it('shows admin gate for non-admin users', () => {
mockIsAdmin = false;
renderPage();
expect(screen.getByTestId('outbox-admin-only')).toBeInTheDocument();
expect(screen.queryByTestId('outbox-page')).not.toBeInTheDocument();
});
it('shows empty DLQ state when no failed events', () => {
renderPage();
expect(screen.getByTestId('outbox-dlq-empty')).toBeInTheDocument();
});
it('renders failed event cards with error message', () => {
mockEvents = [makeEvent()];
renderPage();
expect(screen.getByTestId('outbox-event-11111111-1111-1111-1111-111111111111')).toBeInTheDocument();
expect(screen.getByTestId('outbox-error-11111111-1111-1111-1111-111111111111')).toHaveTextContent(
'handler crashed',
);
expect(screen.getByText('contact.created')).toBeInTheDocument();
});
it('replays a single failed event on button click', async () => {
mockEvents = [makeEvent()];
renderPage();
fireEvent.click(screen.getByTestId('outbox-replay-11111111-1111-1111-1111-111111111111'));
await waitFor(() => expect(replayMut).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111'));
});
it('replays all failed events via replay-all button', async () => {
mockEvents = [makeEvent(), makeEvent({ id: '22222222-2222-2222-2222-222222222222' })];
renderPage();
fireEvent.click(screen.getByTestId('outbox-replay-all'));
await waitFor(() => expect(replayAllMut).toHaveBeenCalled());
});
it('opens recover-stuck modal, adjusts timeout and confirms', async () => {
renderPage();
fireEvent.click(screen.getByTestId('outbox-recover-stuck'));
const input = screen.getByTestId('outbox-timeout-input');
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '300' } });
fireEvent.click(screen.getByTestId('outbox-maintenance-confirm'));
await waitFor(() => expect(recoverMut).toHaveBeenCalledWith(300, expect.anything()));
});
it('opens cleanup-published modal, adjusts retention and confirms', async () => {
renderPage();
fireEvent.click(screen.getByTestId('outbox-cleanup-published'));
const input = screen.getByTestId('outbox-retention-input');
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '14' } });
fireEvent.click(screen.getByTestId('outbox-maintenance-confirm'));
await waitFor(() => expect(cleanupMut).toHaveBeenCalledWith(14, expect.anything()));
});
it('renders consumer registry entries with handlers', () => {
mockRegistry = { 'contact.created': ['audit_log_handler', 'search_index_handler'] };
renderPage();
expect(screen.getByTestId('outbox-registry-contact.created')).toBeInTheDocument();
expect(screen.getByText('audit_log_handler')).toBeInTheDocument();
expect(screen.getByText('search_index_handler')).toBeInTheDocument();
});
it('shows empty registry state when no handlers registered', () => {
renderPage();
expect(screen.getByTestId('outbox-registry-empty')).toBeInTheDocument();
});
});