Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/__tests__/pages/ingest-csv.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<IngestPage />);
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: {
Expand Down
159 changes: 159 additions & 0 deletions frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Normalized document viewer</div>,
}));

// Data-free placeholders: two {} events. Shapes below are copied from the
// backend source; counts derive from EVENTS.length.
const EVENTS: Record<string, unknown>[] = [{}, {}];

// _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(<IngestPage />);
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(<IngestPage />);
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);
});
});
13 changes: 7 additions & 6 deletions frontend/src/__tests__/pages/ingest-epcis.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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();
});

Expand Down
48 changes: 35 additions & 13 deletions frontend/src/app/ingest/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
parseEpcisJson,
normalizeEpcisIngestResult,
epcisErrorMessage,
epcisErrorEventMessages,
type ParsedEpcisJson,
} from '@/lib/epcis-upload';
import { notifyDashboardRefresh } from '@/hooks/use-dashboard-refresh';
Expand Down Expand Up @@ -122,6 +123,9 @@ export default function IngestPage() {
const [jobId, setJobId] = useState<string | null>(null);
const [jobStatus, setJobStatus] = useState<string>('queued');
const [statusError, setStatusError] = useState<string | null>(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<string | null>(null);
// Dashboard "No data yet" tiles deep-link to /ingest?tab=file (CSV upload).
const [activeTab, setActiveTab] = useState<string>(() => {
const tab = searchParams.get('tab');
Expand All @@ -133,6 +137,7 @@ export default function IngestPage() {
const [epcisFile, setEpcisFile] = useState<File | null>(null);
const [epcisResult, setEpcisResult] = useState<NormalizedEpcisResult | null>(null);
const [epcisError, setEpcisError] = useState<string | null>(null);
const [epcisErrorEvents, setEpcisErrorEvents] = useState<string[]>([]);
const [epcisValidation, setEpcisValidation] = useState<{
checked: number;
valid: number;
Expand Down Expand Up @@ -171,6 +176,7 @@ export default function IngestPage() {
setEpcisFile(e.target.files[0]);
setEpcisResult(null);
setEpcisError(null);
setEpcisErrorEvents([]);
setEpcisValidation(null);
}
};
Expand Down Expand Up @@ -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<boolean> => {
// 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
Expand All @@ -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;
};

Expand Down Expand Up @@ -308,6 +324,7 @@ export default function IngestPage() {
notifyDashboardRefresh();
} catch (error) {
setEpcisError(epcisErrorMessage(error));
setEpcisErrorEvents(epcisErrorEventMessages(error));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL error omits workspace error class

Low Severity

A failed API-key save on the URL tab now sets urlError instead of statusError, but PageContainer still keys workspace-load-error off statusError. File and EPCIS API-key failures still apply that class via csvError and epcisError, so the URL tab no longer hides the shell success badge on the same failure.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e200d0. Configure here.

} finally {
submitInFlightRef.current = false;
setIsSubmitting(false);
Expand Down Expand Up @@ -818,14 +835,17 @@ export default function IngestPage() {
</Callout>
)}

{((activeTab === 'url' && ingestMutation.isError) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && (
{((activeTab === 'url' && (urlError || ingestMutation.isError)) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && (
<Callout tone="danger" role="alert" className="mt-4">
<h4 className="font-medium text-re-text-primary">
Ingestion Failed
</h4>
<p className="mt-1">
{(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'}
</p>
{activeTab === 'epcis' && epcisErrorEvents.map((message, index) => (
<p key={index} className="mt-2">{message}</p>
))}
{activeTab === 'file' && (
<ColumnSuggestionList suggestions={errorColumnSuggestions} />
)}
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/lib/epcis-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading