From 31154b9dc6750181ac1577ab01b49cc0a5ba7629 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 13 Sep 2026 23:13:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(outbox):=20UI=20fuer=20Event-Outbox=20?= =?UTF-8?q?=E2=80=94=20Modul=209/16=20des=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/__tests__/pages/Outbox.test.tsx | 180 +++++++ frontend/src/api/outbox.ts | 130 +++++ frontend/src/components/layout/Sidebar.tsx | 7 +- frontend/src/i18n/locales/de.json | 33 ++ frontend/src/i18n/locales/en.json | 33 ++ frontend/src/pages/Outbox.tsx | 481 +++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 7 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/pages/Outbox.test.tsx create mode 100644 frontend/src/api/outbox.ts create mode 100644 frontend/src/pages/Outbox.tsx diff --git a/frontend/src/__tests__/pages/Outbox.test.tsx b/frontend/src/__tests__/pages/Outbox.test.tsx new file mode 100644 index 0000000..1e02f20 --- /dev/null +++ b/frontend/src/__tests__/pages/Outbox.test.tsx @@ -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 => ({ + 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 = {}; +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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/api/outbox.ts b/frontend/src/api/outbox.ts new file mode 100644 index 0000000..1237dfc --- /dev/null +++ b/frontend/src/api/outbox.ts @@ -0,0 +1,130 @@ +/** + * Outbox API client — transactional outbox monitoring & management. + * + * Backend: /api/v1/outbox (stats, failed, replay, replay-all, + * consumer-registry, recover-stuck, cleanup-published). + * All endpoints require the admin role (require_admin, enforced server-side). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost } from '@/api/client'; + +export interface OutboxStats { + /** Count per status: pending, processing, published, failed, no_handlers. */ + counts: Record; + total: number; + oldest_pending_age_seconds: number | null; +} + +export interface FailedOutboxEvent { + id: string; + tenant_id: string; + event_name: string; + error_message: string | null; + failed_at: string | null; + attempts: number; + created_at: string | null; + status: string; +} + +export interface FailedEventsResponse { + events: FailedOutboxEvent[]; + limit: number; + offset: number; + count: number; +} + +export interface ConsumerRegistryResponse { + registry: Record; +} + +export interface ReplaySingleResponse { + replayed: boolean; + event_id: string; +} + +export interface ReplayAllResponse { + replayed_count: number; + tenant_id: string; +} + +export interface RecoverStuckResponse { + recovered_count: number; + timeout_seconds: number; +} + +export interface CleanupPublishedResponse { + deleted_count: number; + retention_days: number; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useOutboxStats(enabled = true) { + return useQuery({ + queryKey: ['outbox', 'stats'], + queryFn: () => apiGet('/outbox/stats'), + enabled, + }); +} + +export function useOutboxFailedEvents(limit: number, offset: number, enabled = true) { + return useQuery({ + queryKey: ['outbox', 'failed', limit, offset], + queryFn: () => + apiGet(`/outbox/failed?limit=${limit}&offset=${offset}`), + enabled, + }); +} + +export function useOutboxConsumerRegistry(enabled = true) { + return useQuery({ + queryKey: ['outbox', 'consumer-registry'], + queryFn: () => apiGet('/outbox/consumer-registry'), + enabled, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useReplayFailedEvent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (eventId) => apiPost(`/outbox/replay/${eventId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['outbox'] }); + }, + }); +} + +export function useReplayAllFailedEvents() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => apiPost('/outbox/replay-all'), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['outbox'] }); + }, + }); +} + +export function useRecoverStuckEvents() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (timeoutSeconds) => + apiPost(`/outbox/recover-stuck?timeout_seconds=${timeoutSeconds}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['outbox'] }); + }, + }); +} + +export function useCleanupPublishedEvents() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (retentionDays) => + apiPost(`/outbox/cleanup-published?retention_days=${retentionDays}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['outbox'] }); + }, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 3976e1c..cb34a6a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -58,6 +58,7 @@ const singleItems: NavSingleItem[] = [ { to: '/system-dashboard', labelKey: 'nav.systemDashboard', icon: