feat(outbox): UI fuer Event-Outbox — Modul 9/16 des UI-Backlogs
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user