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

@@ -118,7 +118,6 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
:disabled="isFieldLocked(field)"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">

View File

@@ -114,7 +114,6 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
:disabled="isCustomFieldLocked(index)"
/>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<option value="text">Texte</option>

View File

@@ -106,6 +106,7 @@ import {
type ModelTypeListResponse,
} from "~/services/modelTypes";
import { useToast } from "~/composables/useToast";
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
const DEFAULT_DESCRIPTION =
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
@@ -305,6 +306,7 @@ const confirmDelete = async (item: ModelType) => {
try {
await deleteModelType(item.id);
invalidateEntityTypeCache(item.category);
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
if (items.value.length === 1 && offset.value >= limit.value) {

View File

@@ -80,9 +80,9 @@ export function useCategoryEditGuard (config: GuardConfig) {
return ''
}
if (linkedCount.value === 1) {
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
}
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
})
const submitBlockMessage = computed(() => {

View File

@@ -18,6 +18,7 @@ interface ConstructeurResult {
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false)
const loaded = ref(false)
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
const map = new Map<string, Constructeur>()
@@ -59,7 +60,10 @@ export function useConstructeurs() {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loadConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
if (!search && !options.force && loaded.value) {
return { success: true, data: constructeurs.value }
}
loading.value = true
try {
const query = search ? `?search=${encodeURIComponent(search)}` : ''
@@ -67,6 +71,7 @@ export function useConstructeurs() {
if (result.success) {
const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
if (!search) loaded.value = true
}
return result as ConstructeurResult
} catch (error) {

View File

@@ -50,18 +50,31 @@ const generateCodeFromName = (name: string): string => {
}
// Shared state per category (module-level singletons)
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean> }> = {}
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
function getOrCreateState(category: ModelCategory) {
if (!stateByCategory[category]) {
stateByCategory[category] = {
types: ref<EntityType[]>([]),
loading: ref(false),
loaded: ref(false),
}
}
return stateByCategory[category]
}
/**
* Marks the cache for a given category as stale.
* Next call to `loadTypes()` (without `force`) will refetch from the API.
* Safe to call from event handlers (no setup context required).
*/
export function invalidateEntityTypeCache(category: ModelCategory) {
const state = stateByCategory[category]
if (state) {
state.loaded.value = false
}
}
export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config
const { showSuccess, showError } = useToast()
@@ -72,7 +85,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
description: item.description ?? item.notes ?? null,
})
const loadTypes = async (): Promise<EntityTypeResult> => {
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
if (!options.force && state.loaded.value) {
return { success: true, data: state.types.value }
}
state.loading.value = true
try {
const data = await listModelTypes({
@@ -82,6 +98,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
limit: 200,
})
state.types.value = data.items.map(normalizeItem)
state.loaded.value = true
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }

View File

@@ -1198,6 +1198,11 @@ export function useMachineDetailData(machineId: string) {
syncMachineCustomFields()
initMachineFields()
// Start document loading early (independent of products/links)
const documentPromise = !machineDocumentsLoaded.value
? refreshMachineDocuments()
: Promise.resolve()
if (!(productInventory.value as AnyRecord[]).length) {
try {
await loadProducts()
@@ -1228,9 +1233,8 @@ export function useMachineDetailData(machineId: string) {
collapseAllComponents()
if (!machineDocumentsLoaded.value) {
await refreshMachineDocuments()
}
// Wait for documents if still loading
await documentPromise
} catch (error) {
console.error('Erreur lors du chargement des données:', error)
} finally {
@@ -1238,10 +1242,12 @@ export function useMachineDetailData(machineId: string) {
}
}
const loadInitialData = () => {
if (!constructeurs.value.length) loadConstructeurs()
if (!componentTypes.value.length) loadComponentTypes()
if (!pieceTypes.value.length) loadPieceTypes()
const loadInitialData = (): Promise<unknown[]> => {
return Promise.all([
loadConstructeurs(),
loadComponentTypes(),
loadPieceTypes(),
])
}
// ---------------------------------------------------------------------------

View File

@@ -24,6 +24,7 @@ export interface MachineType {
const machineTypes = ref<MachineType[]>([])
const loading = ref(false)
const loaded = ref(false)
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
if (!Array.isArray(value)) {
@@ -59,10 +60,11 @@ const normalizeMachineType = (type: Record<string, unknown>): MachineType | null
}
export function useMachineTypesApi() {
const { showSuccess, showInfo } = useToast()
const { showSuccess } = useToast()
const { get, post, put, delete: del } = useApi()
const loadMachineTypes = async (): Promise<void> => {
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/type_machines')
@@ -71,7 +73,7 @@ export function useMachineTypesApi() {
machineTypes.value = items
.map((item) => normalizeMachineType(item as Record<string, unknown>))
.filter((item): item is MachineType => item !== null)
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des types de machines:', error)

View File

@@ -17,6 +17,7 @@ export interface Machine {
const machines = ref<Machine[]>([])
const loading = ref(false)
const loaded = ref(false)
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
if (!source || typeof source !== 'object') {
@@ -73,10 +74,11 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
}
export function useMachines() {
const { showSuccess, showError, showInfo } = useToast()
const { showSuccess, showError } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadMachines = async (): Promise<void> => {
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/machines')
@@ -86,7 +88,7 @@ export function useMachines() {
.map((item) => normalizeMachineResponse(item))
.filter((item): item is Machine => item !== null)
machines.value = normalized
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des machines:', error)

View File

@@ -115,8 +115,16 @@ export function useProducts() {
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false,
} = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,

View File

@@ -24,19 +24,21 @@ interface SiteResult {
const sites = ref<Site[]>([])
const loading = ref(false)
const loaded = ref(false)
export function useSites() {
const { showSuccess, showInfo } = useToast()
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadSites = async (): Promise<void> => {
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/sites')
if (result.success) {
const collection = extractCollection(result.data)
sites.value = collection
showInfo(`Chargement de ${collection.length} site(s) réussi`)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des sites:', error)

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadComponentTypes } = useComponentTypes()
const loading = ref(true)
const saving = ref(false)
@@ -137,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie de composant créée avec succès.')
await router.push('/component-category')
} catch (error: any) {

View File

@@ -185,6 +185,7 @@
</template>
<script setup>
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
import SearchSelect from '~/components/common/SearchSelect.vue'
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
@@ -192,5 +193,5 @@ import RequirementPieceSelector from '~/components/machine/create/RequirementPie
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = useMachineCreatePage()
const c = proxyRefs(useMachineCreatePage())
</script>

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadPieceTypes } = usePieceTypes()
const loading = ref(true)
const saving = ref(false)
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PIECE')
showSuccess('Catégorie de pièce créée avec succès.')
await router.push('/piece-category')
} catch (error: any) {

View File

@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadProductTypes } = useProductTypes()
const loading = ref(true)
const saving = ref(false)
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PRODUCT')
showSuccess('Catégorie de produit créée avec succès.')
await router.push('/product-category')
} catch (error: any) {

23
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
@@ -3255,9 +3256,9 @@
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT"
},
"node_modules/@rollup/plugin-alias": {
@@ -4683,18 +4684,18 @@
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.29"
"@rolldown/pluginutils": "1.0.0-rc.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25"
}
},
@@ -4718,12 +4719,6 @@
"vue": "^3.0.0"
}
},
"node_modules/@vitejs/plugin-vue-jsx/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.40",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.40.tgz",
"integrity": "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",

View File

@@ -30,6 +30,7 @@
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",

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

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
const iconStub = resolve(__dirname, 'tests/__mocks__/iconStub.ts')
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
@@ -9,9 +13,10 @@ export default defineConfig({
include: ['tests/**/*.test.ts'],
},
resolve: {
alias: {
'~': resolve(__dirname, 'app'),
'#imports': resolve(__dirname, 'tests/__mocks__/imports.ts'),
},
alias: [
{ find: /^~icons\/.*/, replacement: iconStub },
{ find: '~', replacement: resolve(__dirname, 'app') },
{ find: '#imports', replacement: resolve(__dirname, 'tests/__mocks__/imports.ts') },
],
},
})