From 67af3c9c46f5dbedca7e7f88702d9f74dbb7d232 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 9 Feb 2026 14:19:08 +0100 Subject: [PATCH] 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 --- app/components/PieceModelStructureEditor.vue | 1 - app/components/StructureNodeEditor.vue | 1 - app/components/model-types/ManagementView.vue | 2 + app/composables/useCategoryEditGuard.ts | 4 +- app/composables/useConstructeurs.ts | 7 +- app/composables/useEntityTypes.ts | 21 +- app/composables/useMachineDetailData.ts | 20 +- app/composables/useMachineTypesApi.ts | 8 +- app/composables/useMachines.ts | 8 +- app/composables/useProducts.ts | 8 + app/composables/useSites.ts | 8 +- app/pages/component-category/[id]/edit.vue | 3 + app/pages/component-category/new.vue | 2 + app/pages/machines/new.vue | 3 +- app/pages/piece-category/[id]/edit.vue | 3 + app/pages/piece-category/new.vue | 2 + app/pages/product-category/[id]/edit.vue | 3 + app/pages/product-category/new.vue | 2 + package-lock.json | 23 +- package.json | 1 + tests/__mocks__/iconStub.ts | 12 + tests/components/ModelTypeForm.test.ts | 361 +++++++++++++ .../PieceModelStructureEditor.test.ts | 396 ++++++++++++++ .../composables/useCategoryEditGuard.test.ts | 269 ++++++++++ tests/composables/useEntityTypes.test.ts | 412 ++++++++++++++ tests/services/modelTypes.test.ts | 228 ++++++++ tests/shared/customFieldFormUtils.test.ts | 508 ++++++++++++++++++ vitest.config.ts | 13 +- 28 files changed, 2287 insertions(+), 42 deletions(-) create mode 100644 tests/__mocks__/iconStub.ts create mode 100644 tests/components/ModelTypeForm.test.ts create mode 100644 tests/components/PieceModelStructureEditor.test.ts create mode 100644 tests/composables/useCategoryEditGuard.test.ts create mode 100644 tests/composables/useEntityTypes.test.ts create mode 100644 tests/services/modelTypes.test.ts create mode 100644 tests/shared/customFieldFormUtils.test.ts diff --git a/app/components/PieceModelStructureEditor.vue b/app/components/PieceModelStructureEditor.vue index 6a31625..18e4f92 100644 --- a/app/components/PieceModelStructureEditor.vue +++ b/app/components/PieceModelStructureEditor.vue @@ -118,7 +118,6 @@ type="text" class="input input-bordered input-xs" placeholder="Nom du champ" - :disabled="isFieldLocked(field)" > diff --git a/app/components/model-types/ManagementView.vue b/app/components/model-types/ManagementView.vue index 4261cf9..9985c90 100644 --- a/app/components/model-types/ManagementView.vue +++ b/app/components/model-types/ManagementView.vue @@ -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) { diff --git a/app/composables/useCategoryEditGuard.ts b/app/composables/useCategoryEditGuard.ts index 852559c..470915f 100644 --- a/app/composables/useCategoryEditGuard.ts +++ b/app/composables/useCategoryEditGuard.ts @@ -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(() => { diff --git a/app/composables/useConstructeurs.ts b/app/composables/useConstructeurs.ts index 9e4f36c..272d939 100644 --- a/app/composables/useConstructeurs.ts +++ b/app/composables/useConstructeurs.ts @@ -18,6 +18,7 @@ interface ConstructeurResult { const constructeurs = ref([]) const loading = ref(false) +const loaded = ref(false) const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => { const map = new Map() @@ -59,7 +60,10 @@ export function useConstructeurs() { const { get, post, patch, delete: del } = useApi() const { showSuccess, showError } = useToast() - const loadConstructeurs = async (search = ''): Promise => { + const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise => { + 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) { diff --git a/app/composables/useEntityTypes.ts b/app/composables/useEntityTypes.ts index f6dfc21..b4d4e0b 100644 --- a/app/composables/useEntityTypes.ts +++ b/app/composables/useEntityTypes.ts @@ -50,18 +50,31 @@ const generateCodeFromName = (name: string): string => { } // Shared state per category (module-level singletons) -const stateByCategory: Record; loading: Ref }> = {} +const stateByCategory: Record; loading: Ref; loaded: Ref }> = {} function getOrCreateState(category: ModelCategory) { if (!stateByCategory[category]) { stateByCategory[category] = { types: ref([]), 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 => { + const loadTypes = async (options: { force?: boolean } = {}): Promise => { + 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 } diff --git a/app/composables/useMachineDetailData.ts b/app/composables/useMachineDetailData.ts index e22c79d..2624980 100644 --- a/app/composables/useMachineDetailData.ts +++ b/app/composables/useMachineDetailData.ts @@ -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 => { + return Promise.all([ + loadConstructeurs(), + loadComponentTypes(), + loadPieceTypes(), + ]) } // --------------------------------------------------------------------------- diff --git a/app/composables/useMachineTypesApi.ts b/app/composables/useMachineTypesApi.ts index 3e5a867..a20e375 100644 --- a/app/composables/useMachineTypesApi.ts +++ b/app/composables/useMachineTypesApi.ts @@ -24,6 +24,7 @@ export interface MachineType { const machineTypes = ref([]) 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): MachineType | null } export function useMachineTypesApi() { - const { showSuccess, showInfo } = useToast() + const { showSuccess } = useToast() const { get, post, put, delete: del } = useApi() - const loadMachineTypes = async (): Promise => { + const loadMachineTypes = async (options: { force?: boolean } = {}): Promise => { + 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)) .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) diff --git a/app/composables/useMachines.ts b/app/composables/useMachines.ts index 48ff8f4..dd589af 100644 --- a/app/composables/useMachines.ts +++ b/app/composables/useMachines.ts @@ -17,6 +17,7 @@ export interface Machine { const machines = ref([]) const loading = ref(false) +const loaded = ref(false) const resolveLinkCollection = (source: Record, 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 => { + const loadMachines = async (options: { force?: boolean } = {}): Promise => { + 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) diff --git a/app/composables/useProducts.ts b/app/composables/useProducts.ts index 07167a0..8ccd937 100644 --- a/app/composables/useProducts.ts +++ b/app/composables/useProducts.ts @@ -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, diff --git a/app/composables/useSites.ts b/app/composables/useSites.ts index b6e23bd..fa9a28c 100644 --- a/app/composables/useSites.ts +++ b/app/composables/useSites.ts @@ -24,19 +24,21 @@ interface SiteResult { const sites = ref([]) 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 => { + const loadSites = async (options: { force?: boolean } = {}): Promise => { + 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) diff --git a/app/pages/component-category/[id]/edit.vue b/app/pages/component-category/[id]/edit.vue index c34e94e..2a0f65f 100644 --- a/app/pages/component-category/[id]/edit.vue +++ b/app/pages/component-category/[id]/edit.vue @@ -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[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) { diff --git a/app/pages/component-category/new.vue b/app/pages/component-category/new.vue index e65056c..f9e21bb 100644 --- a/app/pages/component-category/new.vue +++ b/app/pages/component-category/new.vue @@ -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[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) { diff --git a/app/pages/machines/new.vue b/app/pages/machines/new.vue index 7a63418..7d0280e 100644 --- a/app/pages/machines/new.vue +++ b/app/pages/machines/new.vue @@ -185,6 +185,7 @@ diff --git a/app/pages/piece-category/[id]/edit.vue b/app/pages/piece-category/[id]/edit.vue index bb16bd1..ec71372 100644 --- a/app/pages/piece-category/[id]/edit.vue +++ b/app/pages/piece-category/[id]/edit.vue @@ -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[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) { diff --git a/app/pages/piece-category/new.vue b/app/pages/piece-category/new.vue index 37873cb..385de08 100644 --- a/app/pages/piece-category/new.vue +++ b/app/pages/piece-category/new.vue @@ -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[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) { diff --git a/app/pages/product-category/[id]/edit.vue b/app/pages/product-category/[id]/edit.vue index 945560f..11116ab 100644 --- a/app/pages/product-category/[id]/edit.vue +++ b/app/pages/product-category/[id]/edit.vue @@ -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[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) { diff --git a/app/pages/product-category/new.vue b/app/pages/product-category/new.vue index fc6e8b8..abcedf5 100644 --- a/app/pages/product-category/new.vue +++ b/app/pages/product-category/new.vue @@ -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[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) { diff --git a/package-lock.json b/package-lock.json index 215d979..1eb43ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 83c1a59..2306e6f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/__mocks__/iconStub.ts b/tests/__mocks__/iconStub.ts new file mode 100644 index 0000000..9202108 --- /dev/null +++ b/tests/__mocks__/iconStub.ts @@ -0,0 +1,12 @@ +/** + * Stub for ~icons/* imports (Unplugin Icons). + * Returns a minimal Vue component that renders a . + */ +import { defineComponent, h } from 'vue' + +export default defineComponent({ + name: 'IconStub', + render() { + return h('span', { class: 'icon-stub' }) + }, +}) diff --git a/tests/components/ModelTypeForm.test.ts b/tests/components/ModelTypeForm.test.ts new file mode 100644 index 0000000..c094b53 --- /dev/null +++ b/tests/components/ModelTypeForm.test.ts @@ -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: '
' }, + PieceModelStructureEditor: { template: '
' }, +} + +function mountForm(props: Record = {}) { + 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() + }) +}) diff --git a/tests/components/PieceModelStructureEditor.test.ts b/tests/components/PieceModelStructureEditor.test.ts new file mode 100644 index 0000000..b4c12e8 --- /dev/null +++ b/tests/components/PieceModelStructureEditor.test.ts @@ -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 = {}) { + 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') + }) +}) diff --git a/tests/composables/useCategoryEditGuard.test.ts b/tests/composables/useCategoryEditGuard.test.ts new file mode 100644 index 0000000..894a288 --- /dev/null +++ b/tests/composables/useCategoryEditGuard.test.ts @@ -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() + }) +}) diff --git a/tests/composables/useEntityTypes.test.ts b/tests/composables/useEntityTypes.test.ts new file mode 100644 index 0000000..684ebfc --- /dev/null +++ b/tests/composables/useEntityTypes.test.ts @@ -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) + }) +}) diff --git a/tests/services/modelTypes.test.ts b/tests/services/modelTypes.test.ts new file mode 100644 index 0000000..842c37b --- /dev/null +++ b/tests/services/modelTypes.test.ts @@ -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 => ({ + 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') + }) +}) diff --git a/tests/shared/customFieldFormUtils.test.ts b/tests/shared/customFieldFormUtils.test.ts new file mode 100644 index 0000000..f6e5a98 --- /dev/null +++ b/tests/shared/customFieldFormUtils.test.ts @@ -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'], + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index c8af1f8..62402fe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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') }, + ], }, })