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; }