test(custom-fields) : add data integrity tests for all field types
This commit is contained in:
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
import {
|
||||||
|
shouldPersist,
|
||||||
|
formatValueForSave,
|
||||||
|
} from '~/shared/utils/customFields'
|
||||||
|
import {
|
||||||
|
mockCustomFieldDefs,
|
||||||
|
mockCustomFieldValues,
|
||||||
|
mockMachineCustomFieldDefs,
|
||||||
|
mockMachineCustomFieldValues,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateCustomFieldValue = vi.fn()
|
||||||
|
const mockUpsertCustomFieldValue = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
updateCustomFieldValue: mockUpdateCustomFieldValue,
|
||||||
|
upsertCustomFieldValue: mockUpsertCustomFieldValue,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
|
||||||
|
mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('field initialization', () => {
|
||||||
|
it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for number type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
expect(numberField?.type).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for boolean type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('true')
|
||||||
|
expect(boolField?.type).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for select type with options', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')
|
||||||
|
expect(selectField?.value).toBe('IP65')
|
||||||
|
expect(selectField?.type).toBe('select')
|
||||||
|
expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for date type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('2025-06-15')
|
||||||
|
expect(dateField?.type).toBe('date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for text type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const textField = fields.value.find(f => f.name === 'Remarques techniques')
|
||||||
|
expect(textField?.value).toBe('Roulement renforcé pour environnement humide')
|
||||||
|
expect(textField?.type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaultValue when no persisted value exists', () => {
|
||||||
|
// Pass empty values array so all fields use defaultValue
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('false')
|
||||||
|
|
||||||
|
// No defaultValue → empty string
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
expect(fields.value.every(f => !f.machineContextOnly)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only machineContextOnly in machine context (1 field)', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'machine',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value).toHaveLength(1)
|
||||||
|
expect(fields.value[0]?.name).toBe('Position sur machine')
|
||||||
|
expect(fields.value[0]?.machineContextOnly).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boolean — the tricky case
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean — the tricky case', () => {
|
||||||
|
it('saves "false" value via update (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')!
|
||||||
|
boolField.value = 'false'
|
||||||
|
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists boolean "false" in saveAll (not skipped)', async () => {
|
||||||
|
// Only provide the boolean field def + value
|
||||||
|
const boolDef = mockCustomFieldDefs[1]!
|
||||||
|
const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' }
|
||||||
|
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref([boolDef]),
|
||||||
|
values: ref([boolVal]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value[0]?.value).toBe('false')
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number zero
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('number zero', () => {
|
||||||
|
it('saves "0" value (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockMachineCustomFieldDefs),
|
||||||
|
values: ref(mockMachineCustomFieldValues),
|
||||||
|
entityType: 'machine',
|
||||||
|
entityId: ref('cl-machine-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
|
||||||
|
expect(numField.value).toBe('0')
|
||||||
|
|
||||||
|
await update(numField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text empty string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('text empty string', () => {
|
||||||
|
it('shouldPersist returns false for empty trimmed string', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: ' ',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists non-empty text value', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: 'some text',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(true)
|
||||||
|
expect(formatValueForSave(field)).toBe('some text')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('saves changed option value', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')!
|
||||||
|
selectField.value = 'IP55'
|
||||||
|
|
||||||
|
await update(selectField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('date', () => {
|
||||||
|
it('saves date value in correct format', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')!
|
||||||
|
dateField.value = '2026-01-20'
|
||||||
|
|
||||||
|
await update(dateField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// saveAll isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('saveAll isolation', () => {
|
||||||
|
it('saves all fields independently without losing values', async () => {
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modify one field
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')!
|
||||||
|
numberField.value = '380'
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// All persistable fields should have been saved
|
||||||
|
// 5 fields in standalone context, all have values
|
||||||
|
expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4)
|
||||||
|
|
||||||
|
// The modified field should have the new value
|
||||||
|
const numberCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-001',
|
||||||
|
)
|
||||||
|
expect(numberCall?.[1]).toEqual({ value: '380' })
|
||||||
|
|
||||||
|
// Another field should still have its original value
|
||||||
|
const boolCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-002',
|
||||||
|
)
|
||||||
|
expect(boolCall?.[1]).toEqual({ value: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts new value when no customFieldValueId exists', async () => {
|
||||||
|
// Use defs without matching values — no customFieldValueId
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// Should use upsert since no customFieldValueId
|
||||||
|
expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith(
|
||||||
|
'cf-def-001',
|
||||||
|
'composant',
|
||||||
|
'cl-comp-1',
|
||||||
|
'220',
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns failed field names on error', async () => {
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false })
|
||||||
|
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
const vals = [mockCustomFieldValues[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref(vals),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual(['Tension nominale'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// requiredFilled validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('requiredFilled validation', () => {
|
||||||
|
it('returns true when required fields have values (including defaultValue)', () => {
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// "Tension nominale" is required and has value '220'
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when required field uses defaultValue', () => {
|
||||||
|
// No values provided — required field should use defaultValue '220'
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when required field has no value and no default', () => {
|
||||||
|
// Create a required field with no default and no value
|
||||||
|
const defs = [{
|
||||||
|
id: 'cf-required-no-default',
|
||||||
|
name: 'Required Field',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
}]
|
||||||
|
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user