chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user