d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
/**
|
|
* @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')
|
|
})
|
|
})
|