d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
/**
|
||
* @vitest-environment node
|
||
*/
|
||
import { Readable } from 'node:stream'
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
buildAutoMapping,
|
||
CsvImportValidationError,
|
||
coerceRowsForTable,
|
||
coerceValue,
|
||
createCsvParser,
|
||
csvParseOptions,
|
||
inferColumnType,
|
||
inferSchemaFromCsv,
|
||
parseCsvBuffer,
|
||
sanitizeName,
|
||
validateMapping,
|
||
} from '@/lib/table/import'
|
||
import type { TableSchema } from '@/lib/table/types'
|
||
|
||
describe('import', () => {
|
||
describe('parseCsvBuffer', () => {
|
||
it('parses a CSV string and extracts headers', async () => {
|
||
const { headers, rows } = await parseCsvBuffer('a,b\n1,2\n3,4')
|
||
expect(headers).toEqual(['a', 'b'])
|
||
expect(rows).toEqual([
|
||
{ a: '1', b: '2' },
|
||
{ a: '3', b: '4' },
|
||
])
|
||
})
|
||
|
||
it('strips a UTF-8 BOM from the first header', async () => {
|
||
const text = `\uFEFFname,age\nAlice,30`
|
||
const { headers } = await parseCsvBuffer(text)
|
||
expect(headers).toEqual(['name', 'age'])
|
||
})
|
||
|
||
it('parses a Uint8Array input in browser-like environments', async () => {
|
||
const bytes = new TextEncoder().encode('a,b\n1,2')
|
||
const { headers, rows } = await parseCsvBuffer(bytes)
|
||
expect(headers).toEqual(['a', 'b'])
|
||
expect(rows).toHaveLength(1)
|
||
})
|
||
|
||
it('parses TSV when delimiter is tab', async () => {
|
||
const { headers, rows } = await parseCsvBuffer('a\tb\n1\t2', '\t')
|
||
expect(headers).toEqual(['a', 'b'])
|
||
expect(rows).toEqual([{ a: '1', b: '2' }])
|
||
})
|
||
|
||
it('throws when the file has no data rows', async () => {
|
||
await expect(parseCsvBuffer('a,b')).rejects.toThrow(/no data rows/i)
|
||
})
|
||
})
|
||
|
||
describe('inferColumnType', () => {
|
||
it('returns "string" for empty samples', () => {
|
||
expect(inferColumnType([])).toBe('string')
|
||
expect(inferColumnType([null, undefined, ''])).toBe('string')
|
||
})
|
||
|
||
it('detects numeric columns', () => {
|
||
expect(inferColumnType(['1', '2', '3.14'])).toBe('number')
|
||
})
|
||
|
||
it('detects boolean columns (case-insensitive)', () => {
|
||
expect(inferColumnType(['true', 'FALSE', 'True'])).toBe('boolean')
|
||
})
|
||
|
||
it('detects ISO date columns', () => {
|
||
expect(inferColumnType(['2024-01-01', '2024-02-01T12:00:00'])).toBe('date')
|
||
})
|
||
|
||
it('falls back to "string"', () => {
|
||
expect(inferColumnType(['abc', 'def'])).toBe('string')
|
||
expect(inferColumnType(['1', 'abc'])).toBe('string')
|
||
})
|
||
})
|
||
|
||
describe('sanitizeName', () => {
|
||
it('strips unsupported chars and collapses underscores', () => {
|
||
expect(sanitizeName('Hello World!')).toBe('Hello_World')
|
||
expect(sanitizeName(' foo-bar ')).toBe('foo_bar')
|
||
})
|
||
|
||
it('prefixes names that start with a digit', () => {
|
||
expect(sanitizeName('123abc')).toBe('col_123abc')
|
||
})
|
||
|
||
it('fills in an empty name with the prefix', () => {
|
||
expect(sanitizeName('$$$')).toBe('col_')
|
||
})
|
||
})
|
||
|
||
describe('inferSchemaFromCsv', () => {
|
||
it('produces sanitized column names and inferred types', () => {
|
||
const { columns, headerToColumn } = inferSchemaFromCsv(
|
||
['First Name', 'Age', 'Active'],
|
||
[
|
||
{ 'First Name': 'Alice', Age: '30', Active: 'true' },
|
||
{ 'First Name': 'Bob', Age: '40', Active: 'false' },
|
||
]
|
||
)
|
||
expect(columns).toEqual([
|
||
{ name: 'First_Name', type: 'string' },
|
||
{ name: 'Age', type: 'number' },
|
||
{ name: 'Active', type: 'boolean' },
|
||
])
|
||
expect(headerToColumn.get('First Name')).toBe('First_Name')
|
||
expect(headerToColumn.get('Age')).toBe('Age')
|
||
})
|
||
|
||
it('disambiguates duplicate sanitized headers', () => {
|
||
const { columns } = inferSchemaFromCsv(
|
||
['a b', 'a-b', 'a.b'],
|
||
[{ 'a b': '1', 'a-b': '2', 'a.b': '3' }]
|
||
)
|
||
expect(columns.map((c) => c.name)).toEqual(['a_b', 'a_b_2', 'a_b_3'])
|
||
})
|
||
})
|
||
|
||
describe('coerceValue', () => {
|
||
it('returns null for empty values', () => {
|
||
expect(coerceValue(null, 'string')).toBeNull()
|
||
expect(coerceValue(undefined, 'number')).toBeNull()
|
||
expect(coerceValue('', 'boolean')).toBeNull()
|
||
})
|
||
|
||
it('coerces numbers', () => {
|
||
expect(coerceValue('42', 'number')).toBe(42)
|
||
expect(coerceValue('not a number', 'number')).toBeNull()
|
||
})
|
||
|
||
it('coerces booleans strictly', () => {
|
||
expect(coerceValue('true', 'boolean')).toBe(true)
|
||
expect(coerceValue('FALSE', 'boolean')).toBe(false)
|
||
expect(coerceValue('yes', 'boolean')).toBeNull()
|
||
})
|
||
|
||
it('keeps date-only values as calendar dates, preserves datetime wall times with their offset, and falls back to the original string', () => {
|
||
expect(coerceValue('2024-01-01', 'date')).toBe('2024-01-01')
|
||
expect(coerceValue('2024-01-01T12:30:00-07:00', 'date')).toBe('2024-01-01T12:30:00-07:00')
|
||
expect(coerceValue('2024-01-01 12:30', 'date', { timezone: 'America/New_York' })).toBe(
|
||
'2024-01-01T12:30:00-05:00'
|
||
)
|
||
expect(coerceValue('not-a-date', 'date')).toBe('not-a-date')
|
||
})
|
||
})
|
||
|
||
describe('buildAutoMapping', () => {
|
||
const schema: TableSchema = {
|
||
columns: [
|
||
{ name: 'First_Name', type: 'string' },
|
||
{ name: 'age', type: 'number' },
|
||
],
|
||
}
|
||
|
||
it('maps by exact sanitized name', () => {
|
||
const mapping = buildAutoMapping(['First_Name', 'age'], schema)
|
||
expect(mapping).toEqual({ First_Name: 'First_Name', age: 'age' })
|
||
})
|
||
|
||
it('falls back to a case/punctuation-insensitive match', () => {
|
||
const mapping = buildAutoMapping(['first name', 'AGE'], schema)
|
||
expect(mapping).toEqual({ 'first name': 'First_Name', AGE: 'age' })
|
||
})
|
||
|
||
it('returns null for headers without a match', () => {
|
||
const mapping = buildAutoMapping(['unmatched'], schema)
|
||
expect(mapping).toEqual({ unmatched: null })
|
||
})
|
||
})
|
||
|
||
describe('validateMapping', () => {
|
||
const schema: TableSchema = {
|
||
columns: [
|
||
{ name: 'name', type: 'string', required: true },
|
||
{ name: 'age', type: 'number' },
|
||
],
|
||
}
|
||
|
||
it('accepts a valid mapping and lists skipped/unmapped', () => {
|
||
const result = validateMapping({
|
||
csvHeaders: ['name', 'age', 'extra'],
|
||
mapping: { name: 'name', age: 'age', extra: null },
|
||
tableSchema: schema,
|
||
})
|
||
expect(result.mappedHeaders).toEqual(['name', 'age'])
|
||
expect(result.skippedHeaders).toEqual(['extra'])
|
||
expect(result.unmappedColumns).toEqual([])
|
||
expect(result.effectiveMap.get('name')).toBe('name')
|
||
expect(result.effectiveMap.has('extra')).toBe(false)
|
||
})
|
||
|
||
it('throws when a required column is missing', () => {
|
||
expect(() =>
|
||
validateMapping({
|
||
csvHeaders: ['age'],
|
||
mapping: { age: 'age' },
|
||
tableSchema: schema,
|
||
})
|
||
).toThrow(CsvImportValidationError)
|
||
})
|
||
|
||
it('throws when a mapping targets a non-existent column', () => {
|
||
expect(() =>
|
||
validateMapping({
|
||
csvHeaders: ['name'],
|
||
mapping: { name: 'nonexistent' },
|
||
tableSchema: schema,
|
||
})
|
||
).toThrow(/do not exist on the table/)
|
||
})
|
||
|
||
it('throws when multiple headers map to the same column', () => {
|
||
expect(() =>
|
||
validateMapping({
|
||
csvHeaders: ['a', 'b'],
|
||
mapping: { a: 'name', b: 'name' },
|
||
tableSchema: schema,
|
||
})
|
||
).toThrow(/same column/)
|
||
})
|
||
|
||
it('throws when mapping references an unknown CSV header', () => {
|
||
expect(() =>
|
||
validateMapping({
|
||
csvHeaders: ['name'],
|
||
mapping: { name: 'name', bogus: 'age' },
|
||
tableSchema: schema,
|
||
})
|
||
).toThrow(/unknown CSV headers/)
|
||
})
|
||
|
||
it('throws when a mapping value is neither a string nor null', () => {
|
||
expect(() =>
|
||
validateMapping({
|
||
csvHeaders: ['name'],
|
||
mapping: { name: 42 as unknown as string },
|
||
tableSchema: schema,
|
||
})
|
||
).toThrow(/Mapping values must be/)
|
||
})
|
||
})
|
||
|
||
describe('coerceRowsForTable', () => {
|
||
const schema: TableSchema = {
|
||
columns: [
|
||
{ name: 'name', type: 'string' },
|
||
{ name: 'age', type: 'number' },
|
||
{ name: 'active', type: 'boolean' },
|
||
],
|
||
}
|
||
|
||
it('applies the table column type using the effective mapping', () => {
|
||
const rows = coerceRowsForTable(
|
||
[
|
||
{ Name: 'Alice', Age: '30', Active: 'true' },
|
||
{ Name: 'Bob', Age: 'oops', Active: 'false' },
|
||
],
|
||
schema,
|
||
new Map([
|
||
['Name', 'name'],
|
||
['Age', 'age'],
|
||
['Active', 'active'],
|
||
])
|
||
)
|
||
|
||
expect(rows).toEqual([
|
||
{ name: 'Alice', age: 30, active: true },
|
||
{ name: 'Bob', age: null, active: false },
|
||
])
|
||
})
|
||
|
||
it('drops CSV headers absent from the mapping', () => {
|
||
const rows = coerceRowsForTable(
|
||
[{ name: 'Alice', extra: 'keep me out' }],
|
||
schema,
|
||
new Map([['name', 'name']])
|
||
)
|
||
expect(rows).toEqual([{ name: 'Alice' }])
|
||
})
|
||
})
|
||
|
||
describe('createCsvParser', () => {
|
||
async function parseViaStream(csv: string, delimiter = ',') {
|
||
const parser = createCsvParser(delimiter)
|
||
Readable.from([csv]).pipe(parser)
|
||
const rows: Record<string, unknown>[] = []
|
||
for await (const record of parser as AsyncIterable<Record<string, unknown>>) {
|
||
rows.push(record)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
it('streams records keyed by header, matching parseCsvBuffer', async () => {
|
||
const csv = 'name,age\nAlice,30\nBob,40\n'
|
||
const streamed = await parseViaStream(csv)
|
||
const { rows: buffered } = await parseCsvBuffer(csv)
|
||
expect(streamed).toEqual(buffered)
|
||
expect(streamed).toEqual([
|
||
{ name: 'Alice', age: '30' },
|
||
{ name: 'Bob', age: '40' },
|
||
])
|
||
})
|
||
|
||
it('honors a TSV delimiter', async () => {
|
||
const rows = await parseViaStream('name\tage\nAlice\t30\n', '\t')
|
||
expect(rows).toEqual([{ name: 'Alice', age: '30' }])
|
||
})
|
||
|
||
it('strips a leading UTF-8 BOM', async () => {
|
||
const rows = await parseViaStream('name,age\nAlice,30\n')
|
||
expect(Object.keys(rows[0])).toEqual(['name', 'age'])
|
||
})
|
||
})
|
||
|
||
describe('csvParseOptions', () => {
|
||
it('sets columns, bom, and the delimiter', () => {
|
||
expect(csvParseOptions('\t')).toMatchObject({ columns: true, bom: true, delimiter: '\t' })
|
||
})
|
||
})
|
||
})
|