From 3e200d08e131b55259023db23957f2192aca0799 Mon Sep 17 00:00:00 2001
From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:16:32 -0700
Subject: [PATCH 1/5] fix(ingest): list per-event EPCIS batch errors, clear
stale results before API-key save, show URL-tab errors
Supersedes #3578 with the same net changes on a clean branch off main. Original work by DeepSeek.
---
.../src/__tests__/pages/ingest-csv.test.tsx | 15 ++
.../pages/ingest-epcis-batch-errors.test.tsx | 159 ++++++++++++++++++
.../src/__tests__/pages/ingest-epcis.test.tsx | 13 +-
frontend/src/app/ingest/page.tsx | 48 ++++--
frontend/src/lib/epcis-upload.ts | 21 +++
5 files changed, 237 insertions(+), 19 deletions(-)
create mode 100644 frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx
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) {
From 8b41753f290f29c9a45a9764276b8e2ebc77910f Mon Sep 17 00:00:00 2001
From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:36:55 -0700
Subject: [PATCH 2/5] fix(ingest): hide success badge on URL-tab key-save
failure
---
frontend/src/__tests__/pages/ingest-csv.test.tsx | 1 +
frontend/src/app/ingest/page.tsx | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx
index d13d2749a..9fc66214f 100644
--- a/frontend/src/__tests__/pages/ingest-csv.test.tsx
+++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx
@@ -172,6 +172,7 @@ describe('CSV import results on /ingest', () => {
expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument();
expect(screen.getByText('Ingestion Failed')).toBeInTheDocument();
+ expect(document.querySelector('.workspace-load-error')).not.toBeNull();
expect(mocks.ingestURL).not.toHaveBeenCalled();
});
diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx
index ab8670b68..a38086fc6 100644
--- a/frontend/src/app/ingest/page.tsx
+++ b/frontend/src/app/ingest/page.tsx
@@ -430,7 +430,7 @@ export default function IngestPage() {
: 'neutral';
return (
-
+
From f1085e79409b023cc1c87b652cf85a2770fe1747 Mon Sep 17 00:00:00 2001
From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:57:52 -0700
Subject: [PATCH 3/5] fix(ingest): keep active job on failed API-key save;
dedupe EPCIS errors with a Set
---
.../src/__tests__/pages/ingest-csv.test.tsx | 23 +++++++++++++++++++
frontend/src/app/ingest/page.tsx | 5 ++--
frontend/src/lib/epcis-upload.ts | 17 +++++++++++---
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx
index 9fc66214f..a4f36dfe8 100644
--- a/frontend/src/__tests__/pages/ingest-csv.test.tsx
+++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx
@@ -176,6 +176,29 @@ describe('CSV import results on /ingest', () => {
expect(mocks.ingestURL).not.toHaveBeenCalled();
});
+ it('keeps previous job progress visible when API key save fails on subsequent submit', async () => {
+ mocks.ingestURL.mockResolvedValue({ job_id: 'job-previous-123', status: 'queued' });
+ mocks.getIngestionJob.mockResolvedValue({ status: 'queued' });
+ render();
+
+ await userEvent.click(screen.getByRole('tab', { name: /url ingestion/i }));
+ 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('Ingestion Progress')).toBeInTheDocument();
+ expect(screen.getByText('Job job-previous-123')).toBeInTheDocument();
+
+ 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: /ingest|upload & process/i }));
+
+ expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument();
+ expect(screen.getByText('Ingestion Progress')).toBeInTheDocument();
+ expect(screen.getByText('Job job-previous-123')).toBeInTheDocument();
+ });
+
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/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx
index a38086fc6..e750b276b 100644
--- a/frontend/src/app/ingest/page.tsx
+++ b/frontend/src/app/ingest/page.tsx
@@ -218,8 +218,6 @@ export default function IngestPage() {
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);
@@ -251,6 +249,9 @@ export default function IngestPage() {
return false;
}
}
+ // A failed API-key save must keep the previous job tracked and visible.
+ setJobId(null);
+ setJobStatus('queued');
return true;
};
diff --git a/frontend/src/lib/epcis-upload.ts b/frontend/src/lib/epcis-upload.ts
index 2de962658..cd9966081 100644
--- a/frontend/src/lib/epcis-upload.ts
+++ b/frontend/src/lib/epcis-upload.ts
@@ -91,11 +91,15 @@ export function normalizeEpcisIngestResult(data: EpcisIngestResponse | null | un
? record.rejected
: Math.max(total - accepted, 0);
const errorMessages: string[] = [];
+ const seenErrors = new Set();
if (Array.isArray(record.errors)) {
for (const entry of record.errors) {
if (!entry || typeof entry !== 'object') continue;
const message = resultItemMessage(entry as { index?: number; detail?: unknown });
- if (message) errorMessages.push(message);
+ if (message && !seenErrors.has(message)) {
+ seenErrors.add(message);
+ errorMessages.push(message);
+ }
}
}
if (Array.isArray(record.results)) {
@@ -105,7 +109,10 @@ export function normalizeEpcisIngestResult(data: EpcisIngestResponse | null | un
const failed = typeof statusCode === 'number' && statusCode >= 400;
if (!failed) continue;
const message = resultItemMessage(item as { index?: number; detail?: unknown; errors?: unknown; message?: string });
- if (message && !errorMessages.includes(message)) errorMessages.push(message);
+ if (message && !seenErrors.has(message)) {
+ seenErrors.add(message);
+ errorMessages.push(message);
+ }
}
}
return { total, accepted, rejected, errorMessages };
@@ -124,10 +131,14 @@ export function epcisErrorEventMessages(error: unknown): string[] {
const source = isRecord(data.detail) ? data.detail : data;
if (!Array.isArray(source.errors)) return [];
const messages: string[] = [];
+ const seenMessages = new Set();
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);
+ if (message && !seenMessages.has(message)) {
+ seenMessages.add(message);
+ messages.push(message);
+ }
}
return messages;
}
From 37cc138296afe0abca707833c0d7db7638d29f76 Mon Sep 17 00:00:00 2001
From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>
Date: Mon, 26 Oct 2026 22:13:13 -0700
Subject: [PATCH 4/5] fix(ingest): clear URL-tab error on tab change and file
select
---
.../src/__tests__/pages/ingest-csv.test.tsx | 27 +++++++++++++++++++
frontend/src/app/ingest/page.tsx | 13 ++++++---
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx
index a4f36dfe8..26659d17d 100644
--- a/frontend/src/__tests__/pages/ingest-csv.test.tsx
+++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx
@@ -176,6 +176,33 @@ describe('CSV import results on /ingest', () => {
expect(mocks.ingestURL).not.toHaveBeenCalled();
});
+ it('clears URL API-key save error when switching tabs and does not reappear on returning to URL tab', 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(document.querySelector('.workspace-load-error')).not.toBeNull();
+
+ await userEvent.click(screen.getByRole('tab', { name: /file upload/i }));
+
+ expect(screen.queryByText('API key could not be saved. Please try again.')).not.toBeInTheDocument();
+ expect(screen.queryByText('Ingestion Failed')).not.toBeInTheDocument();
+ expect(document.querySelector('.workspace-load-error')).toBeNull();
+
+ await userEvent.click(screen.getByRole('tab', { name: /url ingestion/i }));
+
+ expect(screen.queryByText('API key could not be saved. Please try again.')).not.toBeInTheDocument();
+ expect(screen.queryByText('Ingestion Failed')).not.toBeInTheDocument();
+ expect(document.querySelector('.workspace-load-error')).toBeNull();
+ });
+
it('keeps previous job progress visible when API key save fails on subsequent submit', async () => {
mocks.ingestURL.mockResolvedValue({ job_id: 'job-previous-123', status: 'queued' });
mocks.getIngestionJob.mockResolvedValue({ status: 'queued' });
diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx
index e750b276b..59852ed4f 100644
--- a/frontend/src/app/ingest/page.tsx
+++ b/frontend/src/app/ingest/page.tsx
@@ -131,6 +131,11 @@ export default function IngestPage() {
const tab = searchParams.get('tab');
return tab === 'file' || tab === 'epcis' ? tab : 'url';
});
+
+ const handleTabChange = (tab: string) => {
+ setActiveTab(tab);
+ setUrlError(null);
+ };
const [file, setFile] = useState(null);
const [csvResult, setCsvResult] = useState(null);
const [csvError, setCsvError] = useState(null);
@@ -162,6 +167,7 @@ export default function IngestPage() {
const isPending = isSubmitting || ingestMutation.isPending || ingestFileMutation.isPending || epcisPending;
const handleFileChange = (e: React.ChangeEvent) => {
+ setUrlError(null);
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
setCsvResult(null);
@@ -172,6 +178,7 @@ export default function IngestPage() {
};
const handleEpcisFileChange = (e: React.ChangeEvent) => {
+ setUrlError(null);
if (e.target.files && e.target.files[0]) {
setEpcisFile(e.target.files[0]);
setEpcisResult(null);
@@ -516,7 +523,7 @@ export default function IngestPage() {
+ {activeSaveError && (
+
+ {activeSaveError}
+
+ )}
+