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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+323
View File
@@ -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' })
})
})
})