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
497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { TABLE_LIMITS } from '@/lib/table/constants'
|
|
import {
|
|
type ColumnDefinition,
|
|
coerceRowToSchema,
|
|
coerceRowValues,
|
|
getUniqueColumns,
|
|
type TableSchema,
|
|
validateColumnDefinition,
|
|
validateRowAgainstSchema,
|
|
validateRowSize,
|
|
validateTableName,
|
|
validateTableSchema,
|
|
validateUniqueConstraints,
|
|
} from '@/lib/table/validation'
|
|
|
|
describe('Validation', () => {
|
|
describe('validateTableName', () => {
|
|
it('should accept valid table names', () => {
|
|
const validNames = ['users', 'user_data', '_private', 'Users123', 'a']
|
|
|
|
for (const name of validNames) {
|
|
const result = validateTableName(name)
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toHaveLength(0)
|
|
}
|
|
})
|
|
|
|
it('should reject empty name', () => {
|
|
const result = validateTableName('')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Table name is required')
|
|
})
|
|
|
|
it('should reject null/undefined name', () => {
|
|
const result1 = validateTableName(null as unknown as string)
|
|
expect(result1.valid).toBe(false)
|
|
|
|
const result2 = validateTableName(undefined as unknown as string)
|
|
expect(result2.valid).toBe(false)
|
|
})
|
|
|
|
it('should reject names starting with number', () => {
|
|
const result = validateTableName('123table')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must start with letter or underscore')
|
|
})
|
|
|
|
it('should reject names with special characters', () => {
|
|
const invalidNames = ['table-name', 'table.name', 'table name', 'table@name']
|
|
|
|
for (const name of invalidNames) {
|
|
const result = validateTableName(name)
|
|
expect(result.valid).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('should reject names exceeding max length', () => {
|
|
const longName = 'a'.repeat(TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + 1)
|
|
const result = validateTableName(longName)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('exceeds maximum length')
|
|
})
|
|
})
|
|
|
|
describe('validateColumnDefinition', () => {
|
|
it('should accept valid column definition', () => {
|
|
const column: ColumnDefinition = {
|
|
name: 'email',
|
|
type: 'string',
|
|
required: true,
|
|
unique: true,
|
|
}
|
|
const result = validateColumnDefinition(column)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should accept all valid column types', () => {
|
|
const types = ['string', 'number', 'boolean', 'date', 'json'] as const
|
|
|
|
for (const type of types) {
|
|
const result = validateColumnDefinition({ name: 'test', type })
|
|
expect(result.valid).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('should reject empty column name', () => {
|
|
const result = validateColumnDefinition({ name: '', type: 'string' })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Column name is required')
|
|
})
|
|
|
|
it('should reject invalid column type', () => {
|
|
const result = validateColumnDefinition({
|
|
name: 'test',
|
|
type: 'invalid' as any,
|
|
})
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('invalid type')
|
|
})
|
|
|
|
it('should reject column name exceeding max length', () => {
|
|
const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1)
|
|
const result = validateColumnDefinition({ name: longName, type: 'string' })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('exceeds maximum length')
|
|
})
|
|
})
|
|
|
|
describe('validateTableSchema', () => {
|
|
it('should accept valid schema', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'id', type: 'string', required: true, unique: true },
|
|
{ name: 'name', type: 'string', required: true },
|
|
{ name: 'age', type: 'number' },
|
|
],
|
|
}
|
|
const result = validateTableSchema(schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should reject empty columns array', () => {
|
|
const schema: TableSchema = { columns: [] }
|
|
const result = validateTableSchema(schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Schema must have at least one column')
|
|
})
|
|
|
|
it('should reject duplicate column names', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'id', type: 'string' },
|
|
{ name: 'ID', type: 'number' },
|
|
],
|
|
}
|
|
const result = validateTableSchema(schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Duplicate column names found')
|
|
})
|
|
|
|
it('should reject null schema', () => {
|
|
const result = validateTableSchema(null as unknown as TableSchema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Schema is required')
|
|
})
|
|
|
|
it('should reject schema without columns array', () => {
|
|
const result = validateTableSchema({} as TableSchema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Schema must have columns array')
|
|
})
|
|
|
|
it('should reject schema exceeding max columns', () => {
|
|
const columns = Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, i) => ({
|
|
name: `col_${i}`,
|
|
type: 'string' as const,
|
|
}))
|
|
const result = validateTableSchema({ columns })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('exceeds maximum columns')
|
|
})
|
|
})
|
|
|
|
describe('validateRowSize', () => {
|
|
it('should accept row within size limit', () => {
|
|
const data = { name: 'test', value: 123 }
|
|
const result = validateRowSize(data)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should reject row exceeding size limit', () => {
|
|
const largeString = 'a'.repeat(TABLE_LIMITS.MAX_ROW_SIZE_BYTES + 1)
|
|
const data = { content: largeString }
|
|
const result = validateRowSize(data)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('exceeds limit')
|
|
})
|
|
|
|
it('should measure UTF-8 bytes, not UTF-16 code units', () => {
|
|
// '工' is one UTF-16 code unit but three UTF-8 bytes — a char-count check
|
|
// would accept this row at ~1/3 of the real serialized size.
|
|
const chars = Math.ceil(TABLE_LIMITS.MAX_ROW_SIZE_BYTES / 3) + 1
|
|
const result = validateRowSize({ content: '工'.repeat(chars) })
|
|
expect(result.valid).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('validateRowAgainstSchema', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'name', type: 'string', required: true },
|
|
{ name: 'age', type: 'number' },
|
|
{ name: 'active', type: 'boolean' },
|
|
{ name: 'created', type: 'date' },
|
|
{ name: 'metadata', type: 'json' },
|
|
],
|
|
}
|
|
|
|
it('should accept valid row data', () => {
|
|
const data = {
|
|
name: 'John',
|
|
age: 30,
|
|
active: true,
|
|
created: '2024-01-01',
|
|
metadata: { key: 'value' },
|
|
}
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should reject missing required field', () => {
|
|
const data = { age: 30 }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Missing required field: name')
|
|
})
|
|
|
|
it('should reject wrong type for string field', () => {
|
|
const data = { name: 123 }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be string')
|
|
})
|
|
|
|
it('should reject wrong type for number field', () => {
|
|
const data = { name: 'John', age: 'thirty' }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be number')
|
|
})
|
|
|
|
it('should reject NaN for number field', () => {
|
|
const data = { name: 'John', age: Number.NaN }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be number')
|
|
})
|
|
|
|
it('should reject wrong type for boolean field', () => {
|
|
const data = { name: 'John', active: 'yes' }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be boolean')
|
|
})
|
|
|
|
it('should reject invalid date string', () => {
|
|
const data = { name: 'John', created: 'not-a-date' }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be valid date')
|
|
})
|
|
|
|
it('should accept valid ISO date string', () => {
|
|
const data = { name: 'John', created: '2024-01-15T10:30:00Z' }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should accept Date object', () => {
|
|
const data = { name: 'John', created: new Date() }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should allow null for optional fields', () => {
|
|
const data = { name: 'John', age: null }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should allow undefined for optional fields', () => {
|
|
const data = { name: 'John' }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should allow long strings — cell size is bounded by the row byte cap, not per-value', () => {
|
|
const longString = 'a'.repeat(100_000)
|
|
const data = { name: longString }
|
|
const result = validateRowAgainstSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('coerceRowToSchema', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'name', type: 'string', required: true },
|
|
{ name: 'age', type: 'number' },
|
|
{ name: 'founded', type: 'number', required: true },
|
|
{ name: 'active', type: 'boolean' },
|
|
{ name: 'created', type: 'date' },
|
|
{ name: 'metadata', type: 'json' },
|
|
],
|
|
}
|
|
|
|
it('coerces a numeric string to a number in place', () => {
|
|
const data = { name: 'Acme', founded: '1999' }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.founded).toBe(1999)
|
|
})
|
|
|
|
it('nulls an un-coercible value for an optional number column', () => {
|
|
const data = { name: 'Acme', founded: 2000, age: 'unknown' }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.age).toBeNull()
|
|
})
|
|
|
|
it('rejects an un-coercible value for a required number column', () => {
|
|
const data = { name: 'Acme', founded: 'unknown' }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('founded must be number')
|
|
expect(data.founded).toBe('unknown')
|
|
})
|
|
|
|
it('coerces a number to a string for a string column', () => {
|
|
const data = { name: 12345, founded: 2000 }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.name).toBe('12345')
|
|
})
|
|
|
|
it('coerces "true"/"false" strings to booleans', () => {
|
|
const data = { name: 'Acme', founded: 2000, active: 'false' }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.active).toBe(false)
|
|
})
|
|
|
|
it('coerces an epoch number to an ISO date string', () => {
|
|
const epoch = Date.parse('2024-01-15T00:00:00Z')
|
|
const data = { name: 'Acme', founded: 2000, created: epoch }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.created).toBe(new Date(epoch).toISOString())
|
|
})
|
|
|
|
it('coerces a Date instance to an ISO date string', () => {
|
|
const date = new Date('2024-01-15T00:00:00Z')
|
|
const data = { name: 'Acme', founded: 2000, created: date }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.created).toBe(date.toISOString())
|
|
})
|
|
|
|
it('nulls an out-of-range epoch number for an optional date column without throwing', () => {
|
|
const data = { name: 'Acme', founded: 2000, created: 1e20 }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.created).toBeNull()
|
|
})
|
|
|
|
it('nulls an invalid Date instance for an optional date column without throwing', () => {
|
|
const data = { name: 'Acme', founded: 2000, created: new Date('not-a-date') }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data.created).toBeNull()
|
|
})
|
|
|
|
it('leaves already-correct values untouched and passes through json', () => {
|
|
const data = { name: 'Acme', founded: 2000, metadata: { k: 'v' } }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(true)
|
|
expect(data).toEqual({ name: 'Acme', founded: 2000, metadata: { k: 'v' } })
|
|
})
|
|
|
|
it('still rejects a missing required field', () => {
|
|
const data = { name: 'Acme' }
|
|
const result = coerceRowToSchema(data, schema)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Missing required field: founded')
|
|
})
|
|
})
|
|
|
|
describe('coerceRowValues', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'name', type: 'string', required: true },
|
|
{ name: 'founded', type: 'number', required: true },
|
|
{ name: 'age', type: 'number' },
|
|
],
|
|
}
|
|
|
|
it('coerces a partial patch in place without flagging absent required fields', () => {
|
|
const patch = { age: '42' }
|
|
coerceRowValues(patch, schema)
|
|
expect(patch.age).toBe(42)
|
|
})
|
|
|
|
it('nulls an un-coercible optional value in a patch', () => {
|
|
const patch: { age: unknown } = { age: 'nope' }
|
|
coerceRowValues(patch as never, schema)
|
|
expect(patch.age).toBeNull()
|
|
})
|
|
|
|
it('leaves an un-coercible required value in place for downstream validation', () => {
|
|
const patch: { founded: unknown } = { founded: 'nope' }
|
|
coerceRowValues(patch as never, schema)
|
|
expect(patch.founded).toBe('nope')
|
|
})
|
|
})
|
|
|
|
describe('getUniqueColumns', () => {
|
|
it('should return only columns with unique=true', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'id', type: 'string', unique: true },
|
|
{ name: 'email', type: 'string', unique: true },
|
|
{ name: 'name', type: 'string' },
|
|
{ name: 'count', type: 'number', unique: false },
|
|
],
|
|
}
|
|
const result = getUniqueColumns(schema)
|
|
expect(result).toHaveLength(2)
|
|
expect(result.map((c) => c.name)).toEqual(['id', 'email'])
|
|
})
|
|
|
|
it('should return empty array when no unique columns', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'name', type: 'string' },
|
|
{ name: 'value', type: 'number' },
|
|
],
|
|
}
|
|
const result = getUniqueColumns(schema)
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('validateUniqueConstraints', () => {
|
|
const schema: TableSchema = {
|
|
columns: [
|
|
{ name: 'id', type: 'string', unique: true },
|
|
{ name: 'email', type: 'string', unique: true },
|
|
{ name: 'name', type: 'string' },
|
|
],
|
|
}
|
|
|
|
const existingRows = [
|
|
{ id: 'row1', data: { id: 'abc123', email: 'john@example.com', name: 'John' } },
|
|
{ id: 'row2', data: { id: 'def456', email: 'jane@example.com', name: 'Jane' } },
|
|
]
|
|
|
|
it('should accept data with unique values', () => {
|
|
const data = { id: 'xyz789', email: 'new@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should reject duplicate unique value', () => {
|
|
const data = { id: 'abc123', email: 'new@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors[0]).toContain('must be unique')
|
|
expect(result.errors[0]).toContain('abc123')
|
|
})
|
|
|
|
it('should be case-insensitive for string comparisons', () => {
|
|
const data = { id: 'ABC123', email: 'new@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(false)
|
|
})
|
|
|
|
it('should exclude specified row from checks (for updates)', () => {
|
|
const data = { id: 'abc123', email: 'john@example.com', name: 'John Updated' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows, 'row1')
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should allow null values for unique columns', () => {
|
|
const data = { id: null, email: 'new@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should allow undefined values for unique columns', () => {
|
|
const data = { email: 'new@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should report multiple violations', () => {
|
|
const data = { id: 'abc123', email: 'john@example.com', name: 'New User' }
|
|
const result = validateUniqueConstraints(data, schema, existingRows)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toHaveLength(2)
|
|
})
|
|
})
|
|
})
|