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:
Matthieu
2026-02-09 14:19:08 +01:00
parent 634184c2be
commit 67af3c9c46
28 changed files with 2287 additions and 42 deletions

View File

@@ -0,0 +1,12 @@
/**
* Stub for ~icons/* imports (Unplugin Icons).
* Returns a minimal Vue component that renders a <span>.
*/
import { defineComponent, h } from 'vue'
export default defineComponent({
name: 'IconStub',
render() {
return h('span', { class: 'icon-stub' })
},
})

View File

@@ -0,0 +1,361 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
// ---------------------------------------------------------------------------
// Mocks — modelUtils (structure functions)
// ---------------------------------------------------------------------------
vi.mock('~/shared/modelUtils', () => ({
normalizeStructureForEditor: (s: any) => s ?? { customFields: [] },
normalizeStructureForSave: (s: any) => s ?? { customFields: [] },
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
normalizeProductStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
formatStructurePreview: () => 'Component preview',
formatPieceStructurePreview: () => 'Piece preview',
formatProductStructurePreview: () => 'Product preview',
defaultStructure: () => ({ customFields: [] }),
defaultPieceStructure: () => ({ customFields: [], products: [] }),
defaultProductStructure: () => ({ customFields: [], products: [] }),
cloneStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [] })),
clonePieceStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
cloneProductStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const globalStubs = {
ComponentModelStructureEditor: { template: '<div data-testid="comp-editor" />' },
PieceModelStructureEditor: { template: '<div data-testid="piece-editor" />' },
}
function mountForm(props: Record<string, any> = {}) {
return shallowMount(ModelTypeForm, {
props: {
mode: 'create',
initialCategory: 'COMPONENT',
lockCategory: true,
...props,
},
global: {
stubs: globalStubs,
},
})
}
function getNameInput(wrapper: VueWrapper) {
return wrapper.find('input[name="name"]')
}
function getCategorySelect(wrapper: VueWrapper) {
return wrapper.find('select[name="category"]')
}
function getNotesTextarea(wrapper: VueWrapper) {
return wrapper.find('textarea[name="notes"]')
}
function getSubmitButton(wrapper: VueWrapper) {
return wrapper.find('button[type="submit"]')
}
function getCancelButton(wrapper: VueWrapper) {
return wrapper.find('button[type="button"]')
}
async function submitForm(wrapper: VueWrapper) {
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
describe('rendering', () => {
it('renders form with name, category, and notes fields', () => {
const wrapper = mountForm()
expect(getNameInput(wrapper).exists()).toBe(true)
expect(getCategorySelect(wrapper).exists()).toBe(true)
expect(getNotesTextarea(wrapper).exists()).toBe(true)
})
it('shows "Créer" button in create mode', () => {
const wrapper = mountForm({ mode: 'create' })
expect(getSubmitButton(wrapper).text()).toBe('Créer')
})
it('shows "Enregistrer" button in edit mode', () => {
const wrapper = mountForm({ mode: 'edit' })
expect(getSubmitButton(wrapper).text()).toBe('Enregistrer')
})
it('populates form from initialData in edit mode', async () => {
const wrapper = mountForm({
mode: 'edit',
initialData: {
name: 'Existing Type',
code: 'existing-type',
category: 'COMPONENT',
notes: 'Some notes',
},
})
await nextTick()
expect((getNameInput(wrapper).element as HTMLInputElement).value).toBe('Existing Type')
expect((getNotesTextarea(wrapper).element as HTMLTextAreaElement).value).toBe('Some notes')
})
})
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
describe('validation', () => {
it('shows error for name shorter than 2 characters', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('A')
await submitForm(wrapper)
expect(wrapper.text()).toContain('au moins 2 caractères')
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('shows error for name longer than 120 characters', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('A'.repeat(121))
await submitForm(wrapper)
expect(wrapper.text()).toContain('ne peut pas dépasser 120')
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('submits with valid name', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Mon Type')
await submitForm(wrapper)
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toBeTruthy()
})
it('does not show error with valid name', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Valid Name')
await submitForm(wrapper)
expect(wrapper.text()).not.toContain('au moins 2')
expect(wrapper.text()).not.toContain('ne peut pas dépasser')
})
})
// ---------------------------------------------------------------------------
// Code generation
// ---------------------------------------------------------------------------
describe('code generation', () => {
it('auto-generates code from name in create mode', async () => {
const wrapper = mountForm({ mode: 'create' })
await getNameInput(wrapper).setValue('Ma Catégorie')
await nextTick()
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.code).toBe('ma-categorie')
})
it('preserves initialData code in edit mode', async () => {
const wrapper = mountForm({
mode: 'edit',
initialData: {
name: 'Type',
code: 'custom-code',
category: 'COMPONENT',
},
})
await nextTick()
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.code).toBe('custom-code')
})
})
// ---------------------------------------------------------------------------
// Category-based structure editor rendering
// ---------------------------------------------------------------------------
describe('structure editor rendering', () => {
it('renders ComponentModelStructureEditor for COMPONENT', () => {
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(false)
})
it('renders PieceModelStructureEditor for PIECE', () => {
const wrapper = mountForm({ initialCategory: 'PIECE' })
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
})
it('renders PieceModelStructureEditor for PRODUCT', () => {
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
// PRODUCT reuses PieceModelStructureEditor
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Category lock
// ---------------------------------------------------------------------------
describe('category lock', () => {
it('disables category select when lockCategory is true', () => {
const wrapper = mountForm({ lockCategory: true })
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(true)
})
it('enables category select when lockCategory is false', () => {
const wrapper = mountForm({ lockCategory: false })
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('shows restricted mode message', () => {
const wrapper = mountForm({
restrictedMode: true,
restrictedModeMessage: 'Mode restreint actif',
})
expect(wrapper.text()).toContain('Mode restreint actif')
expect(wrapper.find('.alert-info').exists()).toBe(true)
})
it('does not show restricted mode message when not restricted', () => {
const wrapper = mountForm({
restrictedMode: false,
})
expect(wrapper.find('.alert-info').exists()).toBe(false)
})
it('disables name input in restricted mode', () => {
const wrapper = mountForm({ restrictedMode: true })
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Submit disabled
// ---------------------------------------------------------------------------
describe('submit disabled', () => {
it('disables submit button when disableSubmit is true', () => {
const wrapper = mountForm({ disableSubmit: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows warning alert when disableSubmit is true', () => {
const wrapper = mountForm({
disableSubmit: true,
disableSubmitMessage: 'Cannot save now',
})
expect(wrapper.find('.alert-warning').exists()).toBe(true)
expect(wrapper.text()).toContain('Cannot save now')
})
it('does not show warning when disableSubmit is false', () => {
const wrapper = mountForm({ disableSubmit: false })
expect(wrapper.find('.alert-warning').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Saving state
// ---------------------------------------------------------------------------
describe('saving state', () => {
it('disables submit button when saving', () => {
const wrapper = mountForm({ saving: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('disables cancel button when saving', () => {
const wrapper = mountForm({ saving: true })
expect((getCancelButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows spinner when saving', () => {
const wrapper = mountForm({ saving: true })
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Cancel
// ---------------------------------------------------------------------------
describe('cancel', () => {
it('emits cancel on cancel button click', async () => {
const wrapper = mountForm()
await getCancelButton(wrapper).trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Submit payload
// ---------------------------------------------------------------------------
describe('submit payload', () => {
it('emits payload with COMPONENT category and structure', async () => {
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
await getNameInput(wrapper).setValue('Test Component')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('COMPONENT')
expect(payload.name).toBe('Test Component')
expect(payload.structure).toBeDefined()
})
it('emits payload with PIECE category', async () => {
const wrapper = mountForm({ initialCategory: 'PIECE' })
await getNameInput(wrapper).setValue('Test Piece')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('PIECE')
})
it('emits payload with PRODUCT category', async () => {
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
await getNameInput(wrapper).setValue('Test Product')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.category).toBe('PRODUCT')
})
it('includes trimmed notes in payload', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Test')
await getNotesTextarea(wrapper).setValue(' Some notes ')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.notes).toBe('Some notes')
})
it('omits empty notes from payload', async () => {
const wrapper = mountForm()
await getNameInput(wrapper).setValue('Test')
await getNotesTextarea(wrapper).setValue('')
await submitForm(wrapper)
const payload = (wrapper.emitted('submit')![0] as any[])[0]
expect(payload.notes).toBeUndefined()
})
})

View File

@@ -0,0 +1,396 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: ref([]),
loadingProductTypes: ref(false),
loadProductTypes: vi.fn().mockResolvedValue({ success: true }),
createProductType: vi.fn(),
updateProductType: vi.fn(),
deleteProductType: vi.fn(),
getProductTypes: () => [],
isProductTypeLoading: () => false,
}),
}))
vi.mock('~/shared/modelUtils', () => ({
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [] },
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mountEditor(props: Record<string, any> = {}) {
return mount(PieceModelStructureEditor, {
props: {
modelValue: { customFields: [], products: [] },
...props,
},
})
}
function getAddFieldButton(wrapper: VueWrapper) {
// The second "Ajouter" button is for custom fields
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
return buttons[buttons.length - 1]
}
function getAddProductButton(wrapper: VueWrapper) {
// The first "Ajouter" button is for products
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
return buttons[0]
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
describe('empty state', () => {
it('renders with no fields and shows empty message', () => {
const wrapper = mountEditor()
expect(wrapper.text()).toContain('Aucun champ personnalisé')
})
it('renders with no products and shows empty message', () => {
const wrapper = mountEditor()
expect(wrapper.text()).toContain('Aucun produit défini')
})
it('shows add field button', () => {
const wrapper = mountEditor()
expect(getAddFieldButton(wrapper).exists()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Add custom field
// ---------------------------------------------------------------------------
describe('add custom field', () => {
it('adds a new empty field on button click', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
const nameInputs = wrapper.findAll('input[type="text"]')
expect(nameInputs.length).toBeGreaterThanOrEqual(1)
expect(wrapper.text()).not.toContain('Aucun champ personnalisé')
})
it('new field has type "text" by default', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
const selects = wrapper.findAll('select')
// The last select should be the type select for the new field
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).value).toBe('text')
})
it('emits update:modelValue after adding field', async () => {
const wrapper = mountEditor()
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Remove custom field
// ---------------------------------------------------------------------------
describe('remove custom field', () => {
it('removes field on delete button click', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
products: [],
},
})
// Find the delete button (btn-error)
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
await deleteBtn.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('Aucun champ personnalisé')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Field name editing
// ---------------------------------------------------------------------------
describe('field name editing', () => {
it('updates field name on input', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Old Name', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
await nameInput.setValue('New Name')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].name).toBe('New Name')
})
})
// ---------------------------------------------------------------------------
// Field type change
// ---------------------------------------------------------------------------
describe('field type change', () => {
it('changes field type via select', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Size', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// Find the type select (not the product type select)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
await typeSelect.setValue('number')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].type).toBe('number')
})
it('shows options textarea for select type', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: 'Red\nBlue' }],
products: [],
},
})
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('hides options textarea for non-select types', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
products: [],
},
})
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Required checkbox
// ---------------------------------------------------------------------------
describe('required checkbox', () => {
it('toggles required on checkbox change', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Test', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].required).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('allows editing name of pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
expect((nameInput.element as HTMLInputElement).disabled).toBe(false)
})
it('disables type select for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true)
})
it('disables required checkbox for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
})
it('hides delete button for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// btn-error should not exist for locked fields
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(false)
})
it('allows full editing of newly added field', async () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [],
products: [],
},
})
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
// New field should have an editable type select (not disabled)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false)
// Delete button should exist for new field
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
})
it('hides product add button in restricted mode', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: { customFields: [], products: [] },
})
const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
// Only the "add field" button should be visible, not the product one
expect(addButtons.length).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Add product
// ---------------------------------------------------------------------------
describe('add product', () => {
it('adds a product entry on button click', async () => {
const wrapper = mountEditor()
await getAddProductButton(wrapper).trigger('click')
await nextTick()
expect(wrapper.text()).not.toContain('Aucun produit défini')
// Should have a product type select
expect(wrapper.findAll('select').length).toBeGreaterThanOrEqual(1)
})
})
// ---------------------------------------------------------------------------
// Options parsing
// ---------------------------------------------------------------------------
describe('options parsing', () => {
it('parses multiline options text into array', async () => {
const wrapper = mountEditor({
modelValue: {
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: '' }],
products: [],
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Red\nGreen\nBlue')
await nextTick()
const events = wrapper.emitted('update:modelValue')
expect(events).toBeTruthy()
const lastPayload = events![events!.length - 1][0] as any
expect(lastPayload.customFields[0].options).toEqual(['Red', 'Green', 'Blue'])
})
})
// ---------------------------------------------------------------------------
// Hydration from modelValue
// ---------------------------------------------------------------------------
describe('hydration', () => {
it('renders existing fields from modelValue', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [
{ name: 'Weight', type: 'number', required: true, orderIndex: 0 },
{ name: 'Color', type: 'text', required: false, orderIndex: 1 },
],
products: [],
},
})
const nameInputs = wrapper.findAll('input[type="text"]')
expect(nameInputs.length).toBe(2)
expect((nameInputs[0].element as HTMLInputElement).value).toBe('Weight')
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Color')
})
it('sorts fields by orderIndex', () => {
const wrapper = mountEditor({
modelValue: {
customFields: [
{ name: 'Second', type: 'text', required: false, orderIndex: 1 },
{ name: 'First', type: 'text', required: false, orderIndex: 0 },
],
products: [],
},
})
const nameInputs = wrapper.findAll('input[type="text"]')
expect((nameInputs[0].element as HTMLInputElement).value).toBe('First')
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Second')
})
})

View File

@@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockShowInfo = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
apiCall: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showInfo: mockShowInfo,
showSuccess: vi.fn(),
showError: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const GUARD_CONFIG = {
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe('initial state', () => {
it('has linkedCount 0 and restrictedMode false', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.linkedLoading.value).toBe(false)
})
it('has empty messages when no linked items', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.restrictedModeMessage.value).toBe('')
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// loadLinkedCount
// ---------------------------------------------------------------------------
describe('loadLinkedCount', () => {
it('sets linkedCount from API totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(5)
expect(guard.isRestrictedMode.value).toBe(true)
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining('/composants?'),
)
})
it('sets linkedCount 0 when API returns 0 items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('extracts totalItems from hydra:totalItems format', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(3)
})
it('falls back to member.length when no totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { member: [{}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(2)
})
it('falls back to hydra:member.length', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:member': [{}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(1)
})
it('sets linkedCount 0 on API failure', async () => {
mockGet.mockResolvedValue({ success: false })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('sets linkedCount 0 on exception', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
})
it('sends correct filter parameters', async () => {
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('abc-123')
const callUrl = mockGet.mock.calls[0][0] as string
expect(callUrl).toContain('itemsPerPage=1')
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
})
})
// ---------------------------------------------------------------------------
// restrictedModeMessage
// ---------------------------------------------------------------------------
describe('restrictedModeMessage', () => {
it('shows singular message for 1 linked item', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 1 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('1 composant')
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
})
it('shows plural message for multiple linked items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('5 composants')
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
})
it('uses custom labels from config', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 3 },
})
const guard = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
})
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
})
})
// ---------------------------------------------------------------------------
// isSubmitBlocked & submitBlockMessage
// ---------------------------------------------------------------------------
describe('submit blocking', () => {
it('blocks submit during loading', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading state by starting a load without awaiting
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
guard.loadLinkedCount('mt-1')
expect(guard.linkedLoading.value).toBe(true)
expect(guard.isSubmitBlocked.value).toBe(true)
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
})
it('unblocks submit after loading completes', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// guardSubmitOrNotify
// ---------------------------------------------------------------------------
describe('guardSubmitOrNotify', () => {
it('returns false when not blocked', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(false)
expect(mockShowInfo).not.toHaveBeenCalled()
})
it('returns true and shows info when blocked', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading
mockGet.mockReturnValue(new Promise(() => {}))
guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(true)
expect(mockShowInfo).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,412 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityTypes, invalidateEntityTypeCache } from '~/composables/useEntityTypes'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const mockListModelTypes = vi.fn()
const mockCreateModelType = vi.fn()
const mockUpdateModelType = vi.fn()
const mockDeleteModelType = vi.fn()
vi.mock('~/services/modelTypes', () => ({
listModelTypes: (...args: any[]) => mockListModelTypes(...args),
createModelType: (...args: any[]) => mockCreateModelType(...args),
updateModelType: (...args: any[]) => mockUpdateModelType(...args),
deleteModelType: (...args: any[]) => mockDeleteModelType(...args),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fakeItem = (id: string, name: string) => ({
id,
name,
code: name.toLowerCase(),
category: 'COMPONENT' as const,
structure: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
notes: null,
description: null,
})
function resetState() {
// Reset singleton state via public APIs
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
comp.types.value = []
invalidateEntityTypeCache('COMPONENT')
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
piece.types.value = []
invalidateEntityTypeCache('PIECE')
const product = useEntityTypes({ category: 'PRODUCT', label: 'produit' })
product.types.value = []
invalidateEntityTypeCache('PRODUCT')
}
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
// ---------------------------------------------------------------------------
// loadTypes
// ---------------------------------------------------------------------------
describe('loadTypes', () => {
it('fetches from API and stores in types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Type A'), fakeItem('2', 'Type B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(2)
expect(types.value[0].name).toBe('Type A')
expect(mockListModelTypes).toHaveBeenCalledOnce()
expect(mockListModelTypes).toHaveBeenCalledWith({
category: 'COMPONENT',
sort: 'name',
dir: 'asc',
limit: 200,
})
})
it('returns cached data on second call without force', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Second call — should return cache
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(mockListModelTypes).toHaveBeenCalledTimes(1) // NOT called again
})
it('refetches with force: true', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('1', 'Old')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('1', 'New')], total: 1, offset: 0, limit: 200 })
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].name).toBe('Old')
await loadTypes({ force: true })
expect(types.value[0].name).toBe('New')
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
})
it('sets loading during fetch', async () => {
let resolvePromise: (value: any) => void
mockListModelTypes.mockReturnValue(new Promise((resolve) => { resolvePromise = resolve }))
const { loadTypes, loading } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(loading.value).toBe(false)
const promise = loadTypes()
expect(loading.value).toBe(true)
resolvePromise!({ items: [], total: 0, offset: 0, limit: 200 })
await promise
expect(loading.value).toBe(false)
})
it('shows error on API failure', async () => {
mockListModelTypes.mockRejectedValue(new Error('Network error'))
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalledWith(
expect.stringContaining('Network error'),
)
})
it('normalizes items with description fallback', async () => {
mockListModelTypes.mockResolvedValue({
items: [{ ...fakeItem('1', 'Test'), notes: 'From notes', description: null }],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].description).toBe('From notes')
})
})
// ---------------------------------------------------------------------------
// createType
// ---------------------------------------------------------------------------
describe('createType', () => {
it('creates and pushes to types array', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Created'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Created' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Created')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Created'))
})
it('sends correct payload with auto-generated code', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Ma Catégorie'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Ma Catégorie' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Ma Catégorie',
code: 'ma-categorie',
category: 'COMPONENT',
}),
)
})
it('uses provided code if available', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Test'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Test', code: 'custom-code' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({ code: 'custom-code' }),
)
})
it('shows error on API failure without modifying types', async () => {
mockCreateModelType.mockRejectedValue(new Error('Create failed'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Fail' })
expect(result.success).toBe(false)
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// updateType
// ---------------------------------------------------------------------------
describe('updateType', () => {
it('updates the existing item in types array', async () => {
// Pre-populate
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockResolvedValue(fakeItem('1', 'Updated'))
const result = await updateType('1', { name: 'Updated' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Updated')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Updated'))
})
it('shows error on API failure without modifying types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockRejectedValue(new Error('Update failed'))
const result = await updateType('1', { name: 'Bad' })
expect(result.success).toBe(false)
expect(types.value[0].name).toBe('Original')
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// deleteType
// ---------------------------------------------------------------------------
describe('deleteType', () => {
it('removes item from types array', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'A'), fakeItem('2', 'B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value).toHaveLength(2)
mockDeleteModelType.mockResolvedValue(undefined)
const result = await deleteType('1')
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].id).toBe('2')
expect(mockShowSuccess).toHaveBeenCalled()
})
it('shows error on API failure without removing item', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Keep')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockDeleteModelType.mockRejectedValue(new Error('Delete failed'))
const result = await deleteType('1')
expect(result.success).toBe(false)
expect(types.value).toHaveLength(1)
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// invalidateEntityTypeCache
// ---------------------------------------------------------------------------
describe('invalidateEntityTypeCache', () => {
it('forces next loadTypes to refetch', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Without invalidation, wouldn't refetch
invalidateEntityTypeCache('COMPONENT')
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Fresh')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes: loadAgain, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadAgain()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
expect(types.value[0].name).toBe('Fresh')
})
it('does not crash for unknown category', () => {
expect(() => invalidateEntityTypeCache('COMPONENT')).not.toThrow()
})
})
// ---------------------------------------------------------------------------
// Singleton per category
// ---------------------------------------------------------------------------
describe('singleton per category', () => {
it('shares state between same category calls', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Shared')],
total: 1,
offset: 0,
limit: 200,
})
const a = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await a.loadTypes()
const b = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(b.types.value).toHaveLength(1)
expect(b.types.value[0].name).toBe('Shared')
})
it('isolates state between different categories', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Component')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(comp.types.value).toHaveLength(1)
expect(comp.types.value[0].name).toBe('Component')
expect(piece.types.value).toHaveLength(1)
expect(piece.types.value[0].name).toBe('Piece')
})
it('invalidateEntityTypeCache only affects target category', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Comp')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
// Invalidate only COMPONENT
invalidateEntityTypeCache('COMPONENT')
// PIECE should still use cache
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2) // No extra call
// COMPONENT should refetch
mockListModelTypes.mockResolvedValueOnce({ items: [fakeItem('c1', 'Refreshed')], total: 1, offset: 0, limit: 200 })
await comp.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Import AFTER mocking
import {
createModelType,
updateModelType,
deleteModelType,
getModelType,
type ModelType,
type ModelTypePayload,
} from '~/services/modelTypes'
// The service uses useRequestFetch from #imports (explicit import)
// AND useRuntimeConfig as a Nuxt auto-import (bare global).
const mockFetch = vi.fn()
// Mock the explicit #imports module
vi.mock('#imports', () => ({
useRuntimeConfig: () => ({
public: { apiBaseUrl: 'http://test-api:8081/api' },
}),
useRequestFetch: () => mockFetch,
}))
// Also stub the global auto-import (Nuxt makes these globally available)
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBaseUrl: 'http://test-api:8081/api' },
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fakeModelType = (overrides: Partial<ModelType> = {}): ModelType => ({
id: 'mt-1',
name: 'Test Type',
code: 'test-type',
category: 'COMPONENT',
structure: null,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
...overrides,
})
beforeEach(() => {
mockFetch.mockReset()
})
// ---------------------------------------------------------------------------
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
// ---------------------------------------------------------------------------
describe('normalizeModelType (via getModelType)', () => {
it('maps componentSkeleton to structure for COMPONENT', async () => {
const skeleton = { customFields: [{ name: 'Weight' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: null,
componentSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('maps pieceSkeleton to structure for PIECE', async () => {
const skeleton = { customFields: [{ name: 'Size' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PIECE',
structure: null,
pieceSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('maps productSkeleton to structure for PRODUCT', async () => {
const skeleton = { customFields: [{ name: 'Brand' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PRODUCT',
structure: null,
productSkeleton: skeleton as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
})
it('does not override existing structure', async () => {
const existing = { customFields: [{ name: 'Existing' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: existing as any,
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(existing)
})
})
// ---------------------------------------------------------------------------
// createModelType — maps structure to skeleton
// ---------------------------------------------------------------------------
describe('createModelType', () => {
it('sends POST with componentSkeleton for COMPONENT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType())
const payload: ModelTypePayload = {
name: 'New Type',
code: 'new-type',
category: 'COMPONENT',
structure: structure as any,
}
await createModelType(payload)
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types')
expect(options.method).toBe('POST')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
})
it('sends POST with pieceSkeleton for PIECE', async () => {
const structure = { customFields: [], products: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
await createModelType({
name: 'Piece Type',
code: 'piece-type',
category: 'PIECE',
structure: structure as any,
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.pieceSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
})
it('sends POST with productSkeleton for PRODUCT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
await createModelType({
name: 'Product Type',
code: 'product-type',
category: 'PRODUCT',
structure: structure as any,
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.productSkeleton).toEqual(structure)
})
})
// ---------------------------------------------------------------------------
// updateModelType — maps structure to skeleton
// ---------------------------------------------------------------------------
describe('updateModelType', () => {
it('sends PATCH with correct endpoint and skeleton', async () => {
const structure = { customFields: [{ name: 'Updated' }] }
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
name: 'Updated',
code: 'updated',
category: 'COMPONENT',
structure: structure as any,
})
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-1')
expect(options.method).toBe('PATCH')
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
expect(options.body.componentSkeleton).toEqual(structure)
})
it('sends payload without skeleton when no structure', async () => {
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
name: 'Just Name',
code: 'just-name',
category: 'COMPONENT',
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.componentSkeleton).toBeUndefined()
expect(options.body.name).toBe('Just Name')
})
})
// ---------------------------------------------------------------------------
// deleteModelType
// ---------------------------------------------------------------------------
describe('deleteModelType', () => {
it('sends DELETE to correct endpoint', async () => {
mockFetch.mockResolvedValue(undefined)
await deleteModelType('mt-42')
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-42')
expect(options.method).toBe('DELETE')
})
})
// ---------------------------------------------------------------------------
// getModelType
// ---------------------------------------------------------------------------
describe('getModelType', () => {
it('sends GET to correct endpoint', async () => {
mockFetch.mockResolvedValue(fakeModelType({ id: 'mt-99' }))
const result = await getModelType('mt-99')
expect(mockFetch).toHaveBeenCalledOnce()
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types/mt-99')
expect(options.method).toBe('GET')
expect(result.id).toBe('mt-99')
})
})

View 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'],
})
})
})