/** * @vitest-environment node */ import { createLogger } from '@sim/logger' import { describe, expect, it } from 'vitest' import { mapTextractSdkError, parseS3Uri, pollTextractJob, TextractRouteError, } from '@/app/api/tools/textract/shared' const logger = createLogger('TextractSharedTest') describe('parseS3Uri', () => { it('parses a valid s3 URI', () => { expect(parseS3Uri('s3://my-bucket/path/to/doc.pdf')).toEqual({ bucket: 'my-bucket', key: 'path/to/doc.pdf', }) }) it('rejects a malformed URI', () => { expect(() => parseS3Uri('not-an-s3-uri')).toThrow(TextractRouteError) }) it('rejects path traversal in the key', () => { expect(() => parseS3Uri('s3://my-bucket/../secrets.pdf')).toThrow('path traversal') }) }) describe('mapTextractSdkError', () => { it('gives a friendly hint for unsupported PDFs in single-page mode', () => { const mapped = mapTextractSdkError( { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, true ) expect(mapped.status).toBe(400) expect(mapped.message).toContain('Multi-Page (PDF, TIFF via S3)') }) it('omits the multi-page hint for operations without an async mode', () => { const mapped = mapTextractSdkError( { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, true, { hasAsyncMode: false } ) expect(mapped.message).not.toContain('Multi-Page') expect(mapped.message).toContain('Only JPEG, PNG, and single-page PDF files are supported') }) it('does not rewrite the message for non-PDF unsupported documents', () => { const mapped = mapTextractSdkError( { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, false ) expect(mapped.message).toBe('Unsupported document') }) it('uses the SDK http status when under 500', () => { const mapped = mapTextractSdkError( { name: 'InvalidParameterException', message: 'Bad param', $metadata: { httpStatusCode: 400 }, }, false ) expect(mapped.status).toBe(400) expect(mapped.message).toBe('Bad param') }) it('passes through a 5xx SDK status so tool-execution retry logic still fires', () => { const mapped = mapTextractSdkError( { message: 'Internal failure', $metadata: { httpStatusCode: 500 } }, false ) expect(mapped.status).toBe(500) }) it('defaults to 500 when the SDK gives no http status, since that implies a server-side failure', () => { const mapped = mapTextractSdkError({ message: 'Unknown failure' }, false) expect(mapped.status).toBe(500) }) }) describe('pollTextractJob', () => { it('returns immediately on SUCCEEDED with no NextToken', async () => { const result = await pollTextractJob( 'req-1', logger, async () => ({ JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }] }), (accumulated, page) => ({ ...page, Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], }) ) expect(result.JobStatus).toBe('SUCCEEDED') expect(result.Blocks).toHaveLength(1) }) it('follows NextToken pagination and merges pages', async () => { let calls = 0 const result = await pollTextractJob( 'req-2', logger, async (nextToken) => { calls += 1 if (!nextToken) return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], NextToken: 'next' } return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } }, (accumulated, page) => ({ ...accumulated, ...page, Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], }) ) expect(calls).toBe(2) expect(result.Blocks).toHaveLength(2) }) it('preserves fields the first page has but a later page omits (e.g. DocumentMetadata)', async () => { const result = await pollTextractJob<{ JobStatus?: string NextToken?: string Blocks?: unknown[] DocumentMetadata?: { Pages?: number } }>( 'req-4', logger, async (nextToken) => { if (!nextToken) { return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], DocumentMetadata: { Pages: 3 }, NextToken: 'next', } } return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } }, (accumulated, page) => ({ ...accumulated, ...page, Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], }) ) expect(result.Blocks).toHaveLength(2) expect(result.DocumentMetadata).toEqual({ Pages: 3 }) }) it('throws a TextractRouteError when the job fails', async () => { await expect( pollTextractJob( 'req-3', logger, async () => ({ JobStatus: 'FAILED', StatusMessage: 'boom' }), (accumulated) => accumulated ) ).rejects.toThrow('Textract job failed: boom') }) })