diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx index a5056e59b..d13d2749a 100644 --- a/frontend/src/__tests__/pages/ingest-csv.test.tsx +++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx @@ -160,6 +160,21 @@ describe('CSV import results on /ingest', () => { expect(mocks.ingestFile).not.toHaveBeenCalled(); }); + it('shows the API-key save error on the URL tab without sending a request', async () => { + mocks.storeApiKey.mockRejectedValue(new Error('API key could not be saved. Please try again.')); + render(); + await userEvent.click(screen.getByRole('tab', { name: /url ingestion/i })); + fireEvent.change(screen.getByPlaceholderText('rge_...'), { target: { value: 'rge_replacement_key' } }); // pragma: allowlist secret + fireEvent.change(screen.getByPlaceholderText('https://example.com/regulatory-document.pdf'), { + target: { value: 'https://example.com/regulatory-document.pdf' }, + }); + await userEvent.click(screen.getByRole('button', { name: /ingest|upload & process/i })); + + expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument(); + expect(screen.getByText('Ingestion Failed')).toBeInTheDocument(); + expect(mocks.ingestURL).not.toHaveBeenCalled(); + }); + it('offers the CTE Jev suggested and re-uploads with it only on confirmation', async () => { const rejected = Object.assign(new Error('No valid rows found: Row 2: No CTE type'), { response: { diff --git a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx new file mode 100644 index 000000000..eb89af9df --- /dev/null +++ b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import IngestPage from '@/app/ingest/page'; + +const mocks = vi.hoisted(() => ({ + ingestURL: vi.fn(), + ingestFile: vi.fn(), + ingestEpcisEvent: vi.fn(), + ingestEpcisBatch: vi.fn(), + ingestEpcisXml: vi.fn(), + validateEpcis: vi.fn(), + getIngestionJob: vi.fn(), + refresh: vi.fn(), + storeApiKey: vi.fn(), +})); + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ apiKey: 'cookie-managed', setApiKey: mocks.storeApiKey }), // pragma: allowlist secret +})); +vi.mock('@/hooks/use-api', () => ({ + useIngestURL: () => ({ mutateAsync: mocks.ingestURL, reset: vi.fn(), isPending: false, isError: false }), + useIngestFile: () => ({ mutateAsync: mocks.ingestFile, reset: vi.fn(), isPending: false, isError: false }), + useIngestEpcisEvent: () => ({ mutateAsync: mocks.ingestEpcisEvent, reset: vi.fn(), isPending: false }), + useIngestEpcisBatch: () => ({ mutateAsync: mocks.ingestEpcisBatch, reset: vi.fn(), isPending: false }), + useIngestEpcisXml: () => ({ mutateAsync: mocks.ingestEpcisXml, reset: vi.fn(), isPending: false }), + useValidateEpcisEvent: () => ({ mutateAsync: mocks.validateEpcis, reset: vi.fn(), isPending: false }), +})); +vi.mock('@/lib/api-client', () => ({ + apiClient: { getIngestionJob: mocks.getIngestionJob }, +})); +vi.mock('@/hooks/use-dashboard-refresh', () => ({ + notifyDashboardRefresh: mocks.refresh, +})); +vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({ + NormalizedDocumentViewer: () =>
Normalized document viewer
, +})); + +// Data-free placeholders: two {} events. Shapes below are copied from the +// backend source; counts derive from EVENTS.length. +const EVENTS: Record[] = [{}, {}]; + +// _validate_epcis (services/ingestion/app/epcis/validation.py) output for {}, +// in order. +const BACKEND_EVENT_ERRORS = [ + ...['type', 'eventTime', 'action', 'bizStep'].map((field) => `Missing required EPCIS field '${field}'`), + 'Unsupported EPCIS event type', + 'Missing traceability lot code (fsma:traceabilityLotCode or cbvmda:lotNumber)', +]; +const STATUS = 400; +const JOINED = BACKEND_EVENT_ERRORS.join(': '); +// Exact message from _ingest_batch_events_db_atomic (persistence.py). +const ATOMIC_MESSAGE = + 'One or more events failed pre-DB validation. No events were persisted. ' + + 'Use ?mode=partial to opt in to per-event processing with HTTP 207 semantics.'; + +function axiosRejection(status: number, data: unknown) { + return Object.assign(new Error(`Request failed with status code ${status}`), { response: { status, data } }); +} + +// Atomic (default, _batch_transactional()): HTTPException(400, detail={...}). +function atomicRejection(events: unknown[]) { + return axiosRejection(STATUS, { + detail: { + error: 'batch_validation_failed', + mode: 'atomic', + message: ATOMIC_MESSAGE, + errors: events.map((_, index) => ({ index, status_code: STATUS, detail: { errors: BACKEND_EVENT_ERRORS } })), + }, + }); +} + +// Partial (?mode=partial or EPCIS_BATCH_TRANSACTIONAL=false): router.py +// returns the payload itself with status 400, no detail wrapper. +function partialRejection(events: unknown[]) { + return axiosRejection(STATUS, { + total: events.length, + created: 0, + failed: events.length, + results: [], + errors: events.map((_, index) => ({ index, detail: { errors: BACKEND_EVENT_ERRORS } })), + mode: 'partial', + }); +} + +async function uploadBatch() { + render(); + await userEvent.click(screen.getByRole('tab', { name: /epcis 2\.0/i })); + const input = document.getElementById('epcis-file-upload') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File([JSON.stringify(EVENTS)], 'events.json', { type: 'application/json' })] }, + }); + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); +} + +function expectEveryEventListed() { + for (let index = 0; index < EVENTS.length; index += 1) { + expect(screen.getByText(`Event ${index + 1}: ${JOINED}`)).toBeInTheDocument(); + } + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(EVENTS.length); +} + +describe('EPCIS all-failed batch shows per-event errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists every event under the atomic message', async () => { + mocks.ingestEpcisBatch.mockRejectedValue(atomicRejection(EVENTS)); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(ATOMIC_MESSAGE)).toBeInTheDocument(); + expectEveryEventListed(); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('lists every event for a partial-mode 400 body', async () => { + const rejection = partialRejection(EVENTS); + mocks.ingestEpcisBatch.mockRejectedValue(rejection); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(rejection.message)).toBeInTheDocument(); + expectEveryEventListed(); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('shows the single-event 400 once, with no Event N: lines', async () => { + // Single-event path: persistence.py raises + // HTTPException(400, detail={"errors": [...]}) for {}. + mocks.ingestEpcisEvent.mockRejectedValue(axiosRejection(STATUS, { detail: { errors: BACKEND_EVENT_ERRORS } })); + render(); + await userEvent.click(screen.getByRole('tab', { name: /epcis 2\.0/i })); + const input = document.getElementById('epcis-file-upload') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File([JSON.stringify(EVENTS[0])], 'event.json', { type: 'application/json' })] }, + }); + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getAllByText(JOINED)).toHaveLength(1); + expect(screen.queryAllByText(/^Event \d+:/)).toHaveLength(0); + expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); + }); + + it('clears the previous per-event errors when the API-key save fails', async () => { + mocks.ingestEpcisBatch.mockRejectedValue(atomicRejection(EVENTS)); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expectEveryEventListed(); + + mocks.storeApiKey.mockRejectedValue(new Error('API key could not be saved. Please try again.')); + fireEvent.change(screen.getByPlaceholderText('rge_...'), { target: { value: 'rge_replacement_key' } }); // pragma: allowlist secret + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); + + expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument(); + expect(screen.queryAllByText(/^Event \d+:/)).toHaveLength(0); + expect(screen.queryByText(ATOMIC_MESSAGE)).not.toBeInTheDocument(); + expect(mocks.ingestEpcisBatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index ccdacf7aa..d5f5dfb65 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -162,11 +162,11 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); - // Atomic (default) rejection: the page shows detail.message only. The - // per-event errors carry no msg/message key, so detailToMessage drops them. + // Atomic (default) rejection: detail.message heads one line per event. expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText(ATOMIC_BATCH_MESSAGE)).toBeInTheDocument(); - expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + expectEveryEventRejected(eventList.length); + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(eventList.length); expect(mocks.refresh).not.toHaveBeenCalled(); }); @@ -184,11 +184,12 @@ describe('EPCIS 2.0 import on /ingest', () => { apiKey: 'cookie-managed', // pragma: allowlist secret events, }); - // Partial rejection: the body has no detail key, so the page falls back - // to the error message and shows no per-event errors. + // Partial rejection: the body has no detail key, so the error message is + // the heading and the body's errors give one line per event. expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText(rejection.message)).toBeInTheDocument(); - expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + expectEveryEventRejected(events.length); + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(events.length); expect(mocks.refresh).not.toHaveBeenCalled(); }); diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx index b30ade7ea..ab8670b68 100644 --- a/frontend/src/app/ingest/page.tsx +++ b/frontend/src/app/ingest/page.tsx @@ -32,6 +32,7 @@ import { parseEpcisJson, normalizeEpcisIngestResult, epcisErrorMessage, + epcisErrorEventMessages, type ParsedEpcisJson, } from '@/lib/epcis-upload'; import { notifyDashboardRefresh } from '@/hooks/use-dashboard-refresh'; @@ -122,6 +123,9 @@ export default function IngestPage() { const [jobId, setJobId] = useState(null); const [jobStatus, setJobStatus] = useState('queued'); const [statusError, setStatusError] = useState(null); + // URL-tab error for a failed API-key save; URL ingest errors otherwise come + // from ingestMutation, which has no setter. + const [urlError, setUrlError] = useState(null); // Dashboard "No data yet" tiles deep-link to /ingest?tab=file (CSV upload). const [activeTab, setActiveTab] = useState(() => { const tab = searchParams.get('tab'); @@ -133,6 +137,7 @@ export default function IngestPage() { const [epcisFile, setEpcisFile] = useState(null); const [epcisResult, setEpcisResult] = useState(null); const [epcisError, setEpcisError] = useState(null); + const [epcisErrorEvents, setEpcisErrorEvents] = useState([]); const [epcisValidation, setEpcisValidation] = useState<{ checked: number; valid: number; @@ -171,6 +176,7 @@ export default function IngestPage() { setEpcisFile(e.target.files[0]); setEpcisResult(null); setEpcisError(null); + setEpcisErrorEvents([]); setEpcisValidation(null); } }; @@ -210,6 +216,26 @@ export default function IngestPage() { // browser proxy reads it from the cookie, then clear prior result state. // Returns false when the caller should stop without sending any request. const prepareSubmit = async (): Promise => { + // Clear the previous upload's results and errors first, so a failed + // API-key save below never leaves stale error lines on screen. + setJobId(null); + setJobStatus('queued'); + setStatusError(null); + setCsvResult(null); + setCsvError(null); + setCteSuggestion(null); + setErrorColumnSuggestions([]); + setEpcisResult(null); + setEpcisError(null); + setEpcisErrorEvents([]); + setEpcisValidation(null); + setUrlError(null); + ingestMutation.reset(); + ingestFileMutation.reset(); + ingestEpcisEventMutation.reset(); + ingestEpcisBatchMutation.reset(); + ingestEpcisXmlMutation.reset(); + validateEpcisMutation.reset(); // Store the API key for future use if (!isAuthenticated && apiKey !== storedApiKey) { // The browser proxy reads the cookie written by setApiKey and ignores @@ -221,20 +247,10 @@ export default function IngestPage() { const message = error instanceof Error ? error.message : 'Unable to save API key'; if (activeTab === 'file') setCsvError(message); else if (activeTab === 'epcis') setEpcisError(message); - else setStatusError(message); + else setUrlError(message); return false; } } - setJobId(null); - setJobStatus('queued'); - setStatusError(null); - setCsvResult(null); - setCsvError(null); - setCteSuggestion(null); - setErrorColumnSuggestions([]); - setEpcisResult(null); - setEpcisError(null); - setEpcisValidation(null); return true; }; @@ -308,6 +324,7 @@ export default function IngestPage() { notifyDashboardRefresh(); } catch (error) { setEpcisError(epcisErrorMessage(error)); + setEpcisErrorEvents(epcisErrorEventMessages(error)); } finally { submitInFlightRef.current = false; setIsSubmitting(false); @@ -818,14 +835,17 @@ export default function IngestPage() { )} - {((activeTab === 'url' && ingestMutation.isError) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && ( + {((activeTab === 'url' && (urlError || ingestMutation.isError)) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && (

Ingestion Failed

- {(activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : ingestMutation.error?.message) || 'An error occurred during ingestion'} + {(activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : urlError || ingestMutation.error?.message) || 'An error occurred during ingestion'}

+ {activeTab === 'epcis' && epcisErrorEvents.map((message, index) => ( +

{message}

+ ))} {activeTab === 'file' && ( )} @@ -878,7 +898,9 @@ export default function IngestPage() { setEpcisFile(null); setEpcisResult(null); setEpcisError(null); + setEpcisErrorEvents([]); setEpcisValidation(null); + setUrlError(null); setJobId(null); setJobStatus('queued'); ingestMutation.reset(); diff --git a/frontend/src/lib/epcis-upload.ts b/frontend/src/lib/epcis-upload.ts index fda18c2d6..2de962658 100644 --- a/frontend/src/lib/epcis-upload.ts +++ b/frontend/src/lib/epcis-upload.ts @@ -111,6 +111,27 @@ export function normalizeEpcisIngestResult(data: EpcisIngestResponse | null | un return { total, accepted, rejected, errorMessages }; } +/** + * Per-event lines for a rejected batch, in both backend shapes: + * atomic (400, detail {error, mode, message, errors:[{index, status_code, detail}]}) + * and partial (400 body with no detail wrapper: {errors:[{index, detail}], ...}). + * Returns [] for any other failure. + */ +export function epcisErrorEventMessages(error: unknown): string[] { + if (!(error instanceof Error)) return []; + const data = (error as Error & { response?: { data?: unknown } }).response?.data; + if (!isRecord(data)) return []; + const source = isRecord(data.detail) ? data.detail : data; + if (!Array.isArray(source.errors)) return []; + const messages: string[] = []; + for (const entry of source.errors) { + if (!isRecord(entry) || typeof entry.index !== 'number') continue; + const message = resultItemMessage(entry as { index?: number; detail?: unknown }); + if (message && !messages.includes(message)) messages.push(message); + } + return messages; +} + /** Render a backend or network failure the same terse way the CSV tab does. */ export function epcisErrorMessage(error: unknown): string { if (error instanceof Error) {