feat: add API optimizations, cache invalidation and comprehensive test suite
- Add abort controllers and request deduplication to composables - Add entity type cache invalidation on create/update/delete flows - Add 179 new tests (utilities, services, composables, components) - Fix Vue runtime warnings in structure editors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
508
tests/shared/customFieldFormUtils.test.ts
Normal file
508
tests/shared/customFieldFormUtils.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toFieldString,
|
||||
fieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveRequiredFlag,
|
||||
resolveOptions,
|
||||
resolveDefaultValue,
|
||||
formatDefaultValue,
|
||||
normalizeCustomField,
|
||||
normalizeCustomFieldInputs,
|
||||
extractStoredCustomFieldValue,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
shouldPersistField,
|
||||
formatValueForPersistence,
|
||||
buildCustomFieldMetadata,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toFieldString
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('toFieldString', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(toFieldString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(toFieldString(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('returns string as-is', () => {
|
||||
expect(toFieldString('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(toFieldString(42)).toBe('42')
|
||||
})
|
||||
|
||||
it('converts boolean to string', () => {
|
||||
expect(toFieldString(true)).toBe('true')
|
||||
expect(toFieldString(false)).toBe('false')
|
||||
})
|
||||
|
||||
it('returns empty string for objects', () => {
|
||||
expect(toFieldString({ foo: 'bar' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fieldKey
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('fieldKey', () => {
|
||||
it('prefers customFieldValueId', () => {
|
||||
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('falls back to id', () => {
|
||||
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('id-1')
|
||||
})
|
||||
|
||||
it('falls back to name-index', () => {
|
||||
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
|
||||
expect(fieldKey(field, 3)).toBe('weight-3')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldName
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldName', () => {
|
||||
it('resolves from name property', () => {
|
||||
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('falls back to key', () => {
|
||||
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
|
||||
})
|
||||
|
||||
it('falls back to label', () => {
|
||||
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('returns empty string for empty object', () => {
|
||||
expect(resolveFieldName({})).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(resolveFieldName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldType', () => {
|
||||
it('resolves valid type', () => {
|
||||
expect(resolveFieldType({ type: 'number' })).toBe('number')
|
||||
})
|
||||
|
||||
it('resolves case-insensitive', () => {
|
||||
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
|
||||
})
|
||||
|
||||
it('falls back to text for unknown type', () => {
|
||||
expect(resolveFieldType({ type: 'blob' })).toBe('text')
|
||||
})
|
||||
|
||||
it('resolves nested value.type', () => {
|
||||
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
|
||||
})
|
||||
|
||||
it('returns text for missing type', () => {
|
||||
expect(resolveFieldType({})).toBe('text')
|
||||
})
|
||||
|
||||
it('handles all allowed types', () => {
|
||||
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
|
||||
expect(resolveFieldType({ type })).toBe(type)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRequiredFlag
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveRequiredFlag', () => {
|
||||
it('resolves boolean true', () => {
|
||||
expect(resolveRequiredFlag({ required: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves boolean false', () => {
|
||||
expect(resolveRequiredFlag({ required: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves nested value.required', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "true"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "1"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false', () => {
|
||||
expect(resolveRequiredFlag({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveOptions', () => {
|
||||
it('resolves array of strings', () => {
|
||||
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with value key', () => {
|
||||
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with label key', () => {
|
||||
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
|
||||
})
|
||||
|
||||
it('falls back to value.options', () => {
|
||||
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
|
||||
})
|
||||
|
||||
it('falls back to value.choices', () => {
|
||||
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
|
||||
})
|
||||
|
||||
it('returns empty array for no options', () => {
|
||||
expect(resolveOptions({})).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out empty strings', () => {
|
||||
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('filters out null values', () => {
|
||||
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveDefaultValue', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(resolveDefaultValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves defaultValue', () => {
|
||||
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
|
||||
})
|
||||
|
||||
it('resolves value (non-object)', () => {
|
||||
expect(resolveDefaultValue({ value: 42 })).toBe(42)
|
||||
})
|
||||
|
||||
it('resolves nested value.defaultValue', () => {
|
||||
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
|
||||
})
|
||||
|
||||
it('returns null when nothing found', () => {
|
||||
expect(resolveDefaultValue({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDefaultValue', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatDefaultValue('text', null)).toBe('')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(formatDefaultValue('number', 42)).toBe('42')
|
||||
})
|
||||
|
||||
it('handles boolean type with true', () => {
|
||||
expect(formatDefaultValue('boolean', 'true')).toBe('true')
|
||||
expect(formatDefaultValue('boolean', true)).toBe('true')
|
||||
expect(formatDefaultValue('boolean', '1')).toBe('true')
|
||||
})
|
||||
|
||||
it('handles boolean type with false', () => {
|
||||
expect(formatDefaultValue('boolean', 'false')).toBe('false')
|
||||
expect(formatDefaultValue('boolean', false)).toBe('false')
|
||||
expect(formatDefaultValue('boolean', '0')).toBe('false')
|
||||
})
|
||||
|
||||
it('unwraps nested defaultValue object', () => {
|
||||
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomField
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomField', () => {
|
||||
it('normalizes a complete field', () => {
|
||||
const result = normalizeCustomField({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
orderIndex: 2,
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
value: '',
|
||||
customFieldId: 'cf-1',
|
||||
customFieldValueId: null,
|
||||
orderIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(normalizeCustomField(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for field without name', () => {
|
||||
expect(normalizeCustomField({ type: 'text' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses fallback index', () => {
|
||||
const result = normalizeCustomField({ name: 'Test' }, 5)
|
||||
expect(result?.orderIndex).toBe(5)
|
||||
})
|
||||
|
||||
it('defaults type to text', () => {
|
||||
const result = normalizeCustomField({ name: 'Field' })
|
||||
expect(result?.type).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomFieldInputs', () => {
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(normalizeCustomFieldInputs(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for structure without customFields', () => {
|
||||
expect(normalizeCustomFieldInputs({})).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes and sorts fields by orderIndex', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [
|
||||
{ name: 'B', orderIndex: 2 },
|
||||
{ name: 'A', orderIndex: 1 },
|
||||
],
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('A')
|
||||
expect(result[1].name).toBe('B')
|
||||
})
|
||||
|
||||
it('filters out invalid fields', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('Valid')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractStoredCustomFieldValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('extractStoredCustomFieldValue', () => {
|
||||
it('returns string directly', () => {
|
||||
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns number directly', () => {
|
||||
expect(extractStoredCustomFieldValue(42)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(extractStoredCustomFieldValue(null)).toBe('')
|
||||
})
|
||||
|
||||
it('extracts .value from object', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
|
||||
})
|
||||
|
||||
it('extracts nested .value.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
|
||||
})
|
||||
|
||||
it('extracts customFieldValue.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldInputs', () => {
|
||||
const definitions = {
|
||||
customFields: [
|
||||
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
|
||||
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
it('builds inputs from definitions without values', () => {
|
||||
const result = buildCustomFieldInputs(definitions, null)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('Weight')
|
||||
expect(result[0].value).toBe('')
|
||||
expect(result[1].name).toBe('Color')
|
||||
})
|
||||
|
||||
it('merges stored values by id', () => {
|
||||
const values = [
|
||||
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[0].value).toBe('42')
|
||||
expect(result[0].customFieldValueId).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('merges stored values by name fallback', () => {
|
||||
const values = [
|
||||
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[1].value).toBe('Blue')
|
||||
})
|
||||
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(buildCustomFieldInputs(null, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requiredCustomFieldsFilled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('requiredCustomFieldsFilled', () => {
|
||||
it('returns true when all required fields are filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when required field is empty', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for non-required empty field', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "false" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "true" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean empty as not filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for empty array', () => {
|
||||
expect(requiredCustomFieldsFilled([])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldPersistField & formatValueForPersistence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('shouldPersistField', () => {
|
||||
it('returns true for non-empty text field', () => {
|
||||
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for empty text field', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for boolean "true"', () => {
|
||||
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for boolean "false"', () => {
|
||||
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for boolean empty', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForPersistence', () => {
|
||||
it('trims text value', () => {
|
||||
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns "true" for boolean true', () => {
|
||||
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
|
||||
})
|
||||
|
||||
it('returns "false" for boolean non-true', () => {
|
||||
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldMetadata', () => {
|
||||
it('builds metadata from field', () => {
|
||||
const field: CustomFieldInput = {
|
||||
id: null, name: 'Color', type: 'select', required: true,
|
||||
options: ['Red', 'Blue'], value: 'Red',
|
||||
customFieldId: null, customFieldValueId: null, orderIndex: 0,
|
||||
}
|
||||
expect(buildCustomFieldMetadata(field)).toEqual({
|
||||
customFieldName: 'Color',
|
||||
customFieldType: 'select',
|
||||
customFieldRequired: true,
|
||||
customFieldOptions: ['Red', 'Blue'],
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user