Skip to content

Commit 01e1e3c

Browse files
committed
test(tables): guard the column-type registry's invariants
Property tests for the registry itself rather than any one type: entries key by their own id, COLUMN_TYPES stays derived, an unknown type degrades to string instead of throwing, only opaque-id types restrict filter operators, only configuration-free types are CSV-inferable, and every type that can reject a draft has a message to show. Plus the metadata-ownership matrix, which pins the generic ownership check to the same answers the hardcoded per-type rules gave. These target the registry's silent-failure class — a wrong jsonbCast or a stray operator whitelist used to be invisible until a filter failed in SQL. Both are verified to fail under mutation.
1 parent 8342514 commit 01e1e3c

1 file changed

Lines changed: 111 additions & 0 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Guards for the column-type registry itself, rather than for any one type.
5+
*
6+
* The registry replaced ~40 hand-maintained `switch` arms whose failure mode
7+
* was silence — a missing arm compared numbers as text or blocked every
8+
* conversion, with nothing to notice. These assert the properties that used to
9+
* be spread across those arms, so a new type either satisfies them or fails
10+
* here.
11+
*/
12+
import { describe, expect, it } from 'vitest'
13+
import {
14+
ALL_COLUMN_TYPES,
15+
COLUMN_TYPE_REGISTRY,
16+
COLUMN_TYPES,
17+
columnTypeById,
18+
isColumnType,
19+
} from '@/lib/table/column-types'
20+
import type { ColumnDefinition } from '@/lib/table/types'
21+
import { validateColumnDefinition } from '@/lib/table/validation'
22+
23+
describe('registry shape', () => {
24+
it('keys every entry by its own id', () => {
25+
for (const [key, definition] of Object.entries(COLUMN_TYPE_REGISTRY)) {
26+
expect(definition.id).toBe(key)
27+
}
28+
})
29+
30+
it('derives COLUMN_TYPES from the registry, with no drift', () => {
31+
expect([...COLUMN_TYPES].sort()).toEqual(Object.keys(COLUMN_TYPE_REGISTRY).sort())
32+
expect(ALL_COLUMN_TYPES).toHaveLength(COLUMN_TYPES.length)
33+
})
34+
35+
it('falls back to string for an unknown type instead of throwing', () => {
36+
// A malformed or future schema must render as text, not crash mid-render.
37+
expect(columnTypeById('percent').id).toBe('string')
38+
expect(columnTypeById(undefined).id).toBe('string')
39+
expect(isColumnType('percent')).toBe(false)
40+
expect(isColumnType('currency')).toBe(true)
41+
})
42+
43+
it('only casts to numeric/timestamptz for types whose storage is actually that', () => {
44+
// A wrong cast makes every filter and sort on the column fail in SQL.
45+
for (const definition of ALL_COLUMN_TYPES) {
46+
if (definition.jsonbCast === null) continue
47+
expect(['numeric', 'timestamptz']).toContain(definition.jsonbCast)
48+
}
49+
expect(COLUMN_TYPE_REGISTRY.currency.jsonbCast).toBe(COLUMN_TYPE_REGISTRY.number.jsonbCast)
50+
})
51+
52+
it('restricts filter operators only for types storing opaque ids', () => {
53+
// Restricting a comparable type would silently drop valid filters.
54+
for (const definition of ALL_COLUMN_TYPES) {
55+
if (definition.filterOperators !== null) {
56+
expect(definition.storesOpaqueIds).toBe(true)
57+
}
58+
}
59+
})
60+
61+
it('never infers a type that needs configuration it cannot supply', () => {
62+
for (const definition of ALL_COLUMN_TYPES) {
63+
if (definition.ownedMetadata.length > 0) expect(definition.inferFromCsv).toBe(false)
64+
}
65+
})
66+
67+
it('gives every type that can reject a draft a message to show', () => {
68+
// Without one, `cleanCellValue` nulls the draft and the edit vanishes with
69+
// no explanation.
70+
for (const definition of ALL_COLUMN_TYPES) {
71+
if (definition.typeaheadPattern) expect(definition.parseErrorMessage).toBeTruthy()
72+
}
73+
})
74+
})
75+
76+
describe('metadata ownership', () => {
77+
const column = (over: Partial<ColumnDefinition>): ColumnDefinition =>
78+
({ name: 'c', type: 'string', ...over }) as ColumnDefinition
79+
const options = [{ id: 'opt_a', name: 'A' }]
80+
81+
it.each`
82+
label | definition | valid | needle
83+
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
84+
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
85+
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
86+
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
87+
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
88+
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
89+
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
90+
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
91+
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
92+
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
93+
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
94+
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
95+
`(
96+
'rejects $label',
97+
({
98+
definition,
99+
valid,
100+
needle,
101+
}: {
102+
definition: ColumnDefinition
103+
valid: boolean
104+
needle: string
105+
}) => {
106+
const result = validateColumnDefinition(definition)
107+
expect(result.valid, result.errors.join('; ')).toBe(valid)
108+
if (!valid) expect(result.errors.join(' ').toLowerCase()).toContain(needle.toLowerCase())
109+
}
110+
)
111+
})

0 commit comments

Comments
 (0)