- 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>
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|