feat(admin): add safe processing monitor and operation correlation
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AdminProcessingPage from './views/AdminProcessingPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { setAuthUserKey } from './auth';
|
||||
|
||||
const get = api.get as jest.Mock;
|
||||
const item = { id: '11111111-1111-4111-8111-111111111111', owner: { id: 'owner', name: 'Synthetic owner', email: 'owner@example.test' }, taskType: 'cv.process', status: 'running', stage: 'cv_extracting', attemptCount: 1, maxAttempts: 3, createdAtUtc: '2026-10-28T12:00:00Z', startedAtUtc: '2026-10-28T12:00:00Z', completedAtUtc: null, lastHeartbeatAtUtc: null, durationSeconds: 8, worker: 'document', workerEnabled: true, failureCode: null, job: null, document: { name: 'CV.pdf', type: 'application/pdf', bytes: 500, pages: 2, state: 'running' } };
|
||||
const listing = { items: [item], total: 1, page: 1, pageSize: 25, counts: { running: 1 }, workers: [{ name: 'AiOperations', flagEnabled: false, individualOperationsRecorded: true }], cvInterpretation: 'deterministic' };
|
||||
function open() { return render(<MemoryRouter><I18nProvider><AdminProcessingPage /></I18nProvider></MemoryRouter>); }
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); setAuthUserKey('admin', false);
|
||||
get.mockReset();
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/events') || url.includes('/events?')
|
||||
? { items: [{ id: '9007199254740993', kind: 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: false, retentionDays: 30 }
|
||||
: url.includes(item.id) ? item : listing }));
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
test('shows safe job metadata and real events with a copyable correlation ID', async () => {
|
||||
open();
|
||||
expect(await screen.findByText('Synthetic owner')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
expect(await screen.findByLabelText('Correlation ID')).toHaveValue(item.id);
|
||||
expect(await screen.findByText(/^Worker claimed job/)).toBeVisible();
|
||||
expect(screen.getAllByText('CV.pdf').length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Processing details' })).toHaveFocus());
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pause stops polling and resume uses an exact string event cursor', async () => {
|
||||
jest.useFakeTimers(); open();
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { jest.advanceTimersByTime(5100); });
|
||||
expect(get.mock.calls.some(([url]) => url.includes('after=9007199254740993'))).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause updates' }));
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(15000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume updates' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(get.mock.calls.length).toBeGreaterThan(count);
|
||||
});
|
||||
|
||||
test('forbidden responses clear metadata and do not retry automatically', async () => {
|
||||
jest.useFakeTimers(); get.mockRejectedValue({ response: { status: 403, data: 'SECRET server error' } });
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Administrator access is required.');
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('malformed successful responses produce a recoverable error instead of rendering raw data', async () => {
|
||||
get.mockResolvedValue({ data: 'SECRET malformed body' });
|
||||
open();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('identity changes abort pending reads and clear the previous administrator view', async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
open(); await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
const signal = get.mock.calls[0][1].signal;
|
||||
get.mockRejectedValue({ response: { status: 403 } });
|
||||
act(() => setAuthUserKey('ordinary'));
|
||||
await act(async () => { finish({ data: listing }); });
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed cycle aborts sibling reads and reconnects with bounded backoff', async () => {
|
||||
jest.useFakeTimers();
|
||||
get.mockRejectedValueOnce(new Error('SECRET transport failure'));
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
|
||||
expect(get.mock.calls[0][1].signal.aborted).toBe(true);
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(14999); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
await act(async () => { jest.advanceTimersByTime(1); });
|
||||
expect(screen.getByText('Synthetic owner')).toBeVisible();
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('older event pages pause updates and retain exact cursors until returning to latest', async () => {
|
||||
jest.useFakeTimers();
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events')
|
||||
? { items: [{ id: url.includes('before=') ? '9007199254740992' : '9007199254740993', kind: url.includes('before=') ? 'Created' : 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: true, retentionDays: 30 }
|
||||
: url.includes(item.id) ? item : listing }));
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Older events' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(get.mock.calls.some(([url]) => url.includes('before=9007199254740993'))).toBe(true);
|
||||
expect(screen.getByText(/^Job created/)).toBeVisible();
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Latest events' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText(/^Worker claimed job/)).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user