diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx
index 5d7ace88e..2df551d2a 100644
--- a/frontend/src/__tests__/pages/ingest-csv.test.tsx
+++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx
@@ -15,11 +15,27 @@ const mocks = vi.hoisted(() => ({
storeApiKey: vi.fn(),
}));
+let ingestURLData: any = undefined;
+
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 }),
+ useIngestURL: () => ({
+ mutateAsync: async (args: any) => {
+ const data = await mocks.ingestURL(args);
+ ingestURLData = data;
+ return data;
+ },
+ reset: vi.fn(() => {
+ ingestURLData = undefined;
+ }),
+ get data() {
+ return ingestURLData;
+ },
+ 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 }),
@@ -33,7 +49,12 @@ vi.mock('@/hooks/use-dashboard-refresh', () => ({
notifyDashboardRefresh: mocks.refresh,
}));
vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({
- NormalizedDocumentViewer: () =>
Normalized document viewer
,
+ NormalizedDocumentViewer: ({ documentId }: { documentId?: string }) => (
+
+
Normalized document viewer
+ {documentId &&
Receipt: {documentId}
}
+
+ ),
}));
async function uploadCsv() {
@@ -71,6 +92,7 @@ function csvResult(accepted: number, rejected: number) {
describe('CSV import results on /ingest', () => {
beforeEach(() => {
vi.clearAllMocks();
+ ingestURLData = undefined;
mocks.storeApiKey.mockResolvedValue(undefined);
});
@@ -173,9 +195,96 @@ 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();
});
+ 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 () => {
+ const jobId = crypto.randomUUID();
+ mocks.ingestURL.mockResolvedValue({ job_id: jobId, 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 ${jobId}`)).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 ${jobId}`)).toBeInTheDocument();
+ });
+
+ it('shows save error in completed view while retaining previous job receipt data on failed resubmit', async () => {
+ const jobId = crypto.randomUUID();
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
+ try {
+ mocks.ingestURL.mockResolvedValue({ job_id: jobId, 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 ${jobId}`)).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();
+
+ mocks.getIngestionJob.mockResolvedValue({ status: 'completed' });
+ await vi.advanceTimersByTimeAsync(2000);
+
+ expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent('API key could not be saved. Please try again.');
+ expect(screen.getByText('Normalized document viewer')).toBeInTheDocument();
+ expect(screen.getByText(`Receipt: ${jobId}`)).toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
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 2f17618ba..25e4b520c 100644
--- a/frontend/src/app/ingest/page.tsx
+++ b/frontend/src/app/ingest/page.tsx
@@ -139,6 +139,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);
@@ -188,6 +193,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);
@@ -198,6 +204,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);
@@ -341,26 +348,12 @@ 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);
+ // Clear previous save-error states first so stale save errors do not stack.
+ setUrlError(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
@@ -376,6 +369,25 @@ export default function IngestPage() {
return false;
}
}
+
+ // Once the API key is successfully saved, clear prior results, errors,
+ // and mutation states, and reset job tracking for the new upload.
+ // A failed API-key save keeps the previous job and receipt data intact.
+ setStatusError(null);
+ setCsvResult(null);
+ setCteSuggestion(null);
+ setErrorColumnSuggestions([]);
+ setEpcisResult(null);
+ setEpcisErrorEvents([]);
+ setEpcisValidation(null);
+ ingestMutation.reset();
+ ingestFileMutation.reset();
+ ingestEpcisEventMutation.reset();
+ ingestEpcisBatchMutation.reset();
+ ingestEpcisXmlMutation.reset();
+ validateEpcisMutation.reset();
+ setJobId(null);
+ setJobStatus('queued');
return true;
};
@@ -553,9 +565,10 @@ export default function IngestPage() {
? 'success'
: 'warning'
: 'neutral';
+ const activeSaveError = activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : urlError;
return (
-
+
@@ -640,7 +653,7 @@ export default function IngestPage() {
+ {activeSaveError && (
+
+ {activeSaveError}
+
+ )}
+
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;
}