Skip to content
Merged
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
113 changes: 111 additions & 2 deletions frontend/src/__tests__/pages/ingest-csv.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -33,7 +49,12 @@ vi.mock('@/hooks/use-dashboard-refresh', () => ({
notifyDashboardRefresh: mocks.refresh,
}));
vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({
NormalizedDocumentViewer: () => <div>Normalized document viewer</div>,
NormalizedDocumentViewer: ({ documentId }: { documentId?: string }) => (
<div>
<div>Normalized document viewer</div>
{documentId && <div>Receipt: {documentId}</div>}
</div>
),
}));

async function uploadCsv() {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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(<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(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(<IngestPage />);

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(<IngestPage />);

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: {
Expand Down
63 changes: 41 additions & 22 deletions frontend/src/app/ingest/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<File | null>(null);
const [csvResult, setCsvResult] = useState<IngestCSVResponse | null>(null);
const [csvError, setCsvError] = useState<string | null>(null);
Expand Down Expand Up @@ -188,6 +193,7 @@ export default function IngestPage() {
const isPending = isSubmitting || ingestMutation.isPending || ingestFileMutation.isPending || epcisPending;

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlError(null);
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
setCsvResult(null);
Expand All @@ -198,6 +204,7 @@ export default function IngestPage() {
};

const handleEpcisFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlError(null);
if (e.target.files && e.target.files[0]) {
setEpcisFile(e.target.files[0]);
setEpcisResult(null);
Expand Down Expand Up @@ -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<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);
// 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
Expand All @@ -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();
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
setJobId(null);
setJobStatus('queued');
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return true;
};

Expand Down Expand Up @@ -553,9 +565,10 @@ export default function IngestPage() {
? 'success'
: 'warning'
: 'neutral';
const activeSaveError = activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : urlError;

return (
<PageContainer className={statusError || csvError || epcisError || ingestMutation.isError || jobStatus === 'failed' ? 'workspace-load-error' : undefined}>
<PageContainer className={statusError || urlError || csvError || epcisError || ingestMutation.isError || jobStatus === 'failed' ? 'workspace-load-error' : undefined}>
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
<div className="mb-6">
<WorkflowStepper currentStep="ingest" completedSteps={isAuthenticated || storedApiKey ? ['setup'] : []} />
</div>
Expand Down Expand Up @@ -640,7 +653,7 @@ export default function IngestPage() {
<Button
type="button"
variant="secondary"
onClick={() => setActiveTab('file')}
onClick={() => handleTabChange('file')}
trailingIcon={<ArrowRight strokeWidth={1.75} aria-hidden="true" />}
>
Continue File Mapping
Expand Down Expand Up @@ -720,7 +733,7 @@ export default function IngestPage() {
)}
</div>}

<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="url">URL Ingestion</TabsTrigger>
<TabsTrigger value="file">File Upload</TabsTrigger>
Expand Down Expand Up @@ -755,7 +768,7 @@ export default function IngestPage() {
type="button"
onClick={() => {
setUrl(example.url);
setActiveTab('url');
handleTabChange('url');
}}
>
{example.label}
Expand Down Expand Up @@ -1052,8 +1065,14 @@ export default function IngestPage() {
</Button>
</div>

{activeSaveError && (
<Callout tone="danger" role="alert">
{activeSaveError}
</Callout>
)}

<NormalizedDocumentViewer
documentId={ingestMutation.data?.document_id || 'DEMO-DOC-ID'}
documentId={ingestMutation.data?.document_id || ingestMutation.data?.doc_id || ingestMutation.data?.job_id || 'DEMO-DOC-ID'}
/>
</CardContent>
</Card>
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/lib/epcis-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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)) {
Expand All @@ -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 };
Expand All @@ -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<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);
if (message && !seenMessages.has(message)) {
seenMessages.add(message);
messages.push(message);
}
}
return messages;
}
Expand Down
Loading