feat: add API optimizations, cache invalidation and comprehensive test suite
- Add abort controllers and request deduplication to composables - Add entity type cache invalidation on create/update/delete flows - Add 179 new tests (utilities, services, composables, components) - Fix Vue runtime warnings in structure editors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,7 +118,6 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isFieldLocked(field)"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
|
||||
@@ -114,7 +114,6 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||||
<option value="text">Texte</option>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ConstructeurResult {
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
|
||||
const map = new Map<string, Constructeur>()
|
||||
@@ -59,7 +60,10 @@ export function useConstructeurs() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const loadConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
|
||||
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
|
||||
if (!search && !options.force && loaded.value) {
|
||||
return { success: true, data: constructeurs.value }
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
@@ -67,6 +71,7 @@ export function useConstructeurs() {
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
if (!search) loaded.value = true
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
|
||||
@@ -50,18 +50,31 @@ const generateCodeFromName = (name: string): string => {
|
||||
}
|
||||
|
||||
// Shared state per category (module-level singletons)
|
||||
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean> }> = {}
|
||||
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
|
||||
|
||||
function getOrCreateState(category: ModelCategory) {
|
||||
if (!stateByCategory[category]) {
|
||||
stateByCategory[category] = {
|
||||
types: ref<EntityType[]>([]),
|
||||
loading: ref(false),
|
||||
loaded: ref(false),
|
||||
}
|
||||
}
|
||||
return stateByCategory[category]
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the cache for a given category as stale.
|
||||
* Next call to `loadTypes()` (without `force`) will refetch from the API.
|
||||
* Safe to call from event handlers (no setup context required).
|
||||
*/
|
||||
export function invalidateEntityTypeCache(category: ModelCategory) {
|
||||
const state = stateByCategory[category]
|
||||
if (state) {
|
||||
state.loaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const { category, label } = config
|
||||
const { showSuccess, showError } = useToast()
|
||||
@@ -72,7 +85,10 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
description: item.description ?? item.notes ?? null,
|
||||
})
|
||||
|
||||
const loadTypes = async (): Promise<EntityTypeResult> => {
|
||||
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
|
||||
if (!options.force && state.loaded.value) {
|
||||
return { success: true, data: state.types.value }
|
||||
}
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
@@ -82,6 +98,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
limit: 200,
|
||||
})
|
||||
state.types.value = data.items.map(normalizeItem)
|
||||
state.loaded.value = true
|
||||
return { success: true, data: state.types.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
|
||||
@@ -1198,6 +1198,11 @@ export function useMachineDetailData(machineId: string) {
|
||||
syncMachineCustomFields()
|
||||
initMachineFields()
|
||||
|
||||
// Start document loading early (independent of products/links)
|
||||
const documentPromise = !machineDocumentsLoaded.value
|
||||
? refreshMachineDocuments()
|
||||
: Promise.resolve()
|
||||
|
||||
if (!(productInventory.value as AnyRecord[]).length) {
|
||||
try {
|
||||
await loadProducts()
|
||||
@@ -1228,9 +1233,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
|
||||
collapseAllComponents()
|
||||
|
||||
if (!machineDocumentsLoaded.value) {
|
||||
await refreshMachineDocuments()
|
||||
}
|
||||
// Wait for documents if still loading
|
||||
await documentPromise
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error)
|
||||
} finally {
|
||||
@@ -1238,10 +1242,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const loadInitialData = () => {
|
||||
if (!constructeurs.value.length) loadConstructeurs()
|
||||
if (!componentTypes.value.length) loadComponentTypes()
|
||||
if (!pieceTypes.value.length) loadPieceTypes()
|
||||
const loadInitialData = (): Promise<unknown[]> => {
|
||||
return Promise.all([
|
||||
loadConstructeurs(),
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface MachineType {
|
||||
|
||||
const machineTypes = ref<MachineType[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
@@ -59,10 +60,11 @@ const normalizeMachineType = (type: Record<string, unknown>): MachineType | null
|
||||
}
|
||||
|
||||
export function useMachineTypesApi() {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async (): Promise<void> => {
|
||||
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
@@ -71,7 +73,7 @@ export function useMachineTypesApi() {
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Machine {
|
||||
|
||||
const machines = ref<Machine[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
@@ -73,10 +74,11 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
}
|
||||
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachines = async (): Promise<void> => {
|
||||
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/machines')
|
||||
@@ -86,7 +88,7 @@ export function useMachines() {
|
||||
.map((item) => normalizeMachineResponse(item))
|
||||
.filter((item): item is Machine => item !== null)
|
||||
machines.value = normalized
|
||||
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des machines:', error)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,19 +24,21 @@ interface SiteResult {
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSites() {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async (): Promise<void> => {
|
||||
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
if (result.success) {
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
|
||||
@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -137,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie de composant créée avec succès.')
|
||||
await router.push('/component-category')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { proxyRefs } from 'vue'
|
||||
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
|
||||
@@ -192,5 +193,5 @@ import RequirementPieceSelector from '~/components/machine/create/RequirementPie
|
||||
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
|
||||
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
||||
|
||||
const c = useMachineCreatePage()
|
||||
const c = proxyRefs(useMachineCreatePage())
|
||||
</script>
|
||||
|
||||
@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadPieceTypes } = usePieceTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
showSuccess('Catégorie de pièce créée avec succès.')
|
||||
await router.push('/piece-category')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -44,11 +44,13 @@ import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadProductTypes } = useProductTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -135,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('PRODUCT')
|
||||
showSuccess('Catégorie de produit créée avec succès.')
|
||||
await router.push('/product-category')
|
||||
} catch (error: any) {
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
tests/__mocks__/iconStub.ts
Normal file
12
tests/__mocks__/iconStub.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Stub for ~icons/* imports (Unplugin Icons).
|
||||
* Returns a minimal Vue component that renders a <span>.
|
||||
*/
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'IconStub',
|
||||
render() {
|
||||
return h('span', { class: 'icon-stub' })
|
||||
},
|
||||
})
|
||||
361
tests/components/ModelTypeForm.test.ts
Normal file
361
tests/components/ModelTypeForm.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — modelUtils (structure functions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('~/shared/modelUtils', () => ({
|
||||
normalizeStructureForEditor: (s: any) => s ?? { customFields: [] },
|
||||
normalizeStructureForSave: (s: any) => s ?? { customFields: [] },
|
||||
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
|
||||
normalizeProductStructureForSave: (s: any) => s ?? { customFields: [], products: [] },
|
||||
formatStructurePreview: () => 'Component preview',
|
||||
formatPieceStructurePreview: () => 'Piece preview',
|
||||
formatProductStructurePreview: () => 'Product preview',
|
||||
defaultStructure: () => ({ customFields: [] }),
|
||||
defaultPieceStructure: () => ({ customFields: [], products: [] }),
|
||||
defaultProductStructure: () => ({ customFields: [], products: [] }),
|
||||
cloneStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [] })),
|
||||
clonePieceStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
|
||||
cloneProductStructure: (s: any) => JSON.parse(JSON.stringify(s ?? { customFields: [], products: [] })),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const globalStubs = {
|
||||
ComponentModelStructureEditor: { template: '<div data-testid="comp-editor" />' },
|
||||
PieceModelStructureEditor: { template: '<div data-testid="piece-editor" />' },
|
||||
}
|
||||
|
||||
function mountForm(props: Record<string, any> = {}) {
|
||||
return shallowMount(ModelTypeForm, {
|
||||
props: {
|
||||
mode: 'create',
|
||||
initialCategory: 'COMPONENT',
|
||||
lockCategory: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: globalStubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getNameInput(wrapper: VueWrapper) {
|
||||
return wrapper.find('input[name="name"]')
|
||||
}
|
||||
|
||||
function getCategorySelect(wrapper: VueWrapper) {
|
||||
return wrapper.find('select[name="category"]')
|
||||
}
|
||||
|
||||
function getNotesTextarea(wrapper: VueWrapper) {
|
||||
return wrapper.find('textarea[name="notes"]')
|
||||
}
|
||||
|
||||
function getSubmitButton(wrapper: VueWrapper) {
|
||||
return wrapper.find('button[type="submit"]')
|
||||
}
|
||||
|
||||
function getCancelButton(wrapper: VueWrapper) {
|
||||
return wrapper.find('button[type="button"]')
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: VueWrapper) {
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('rendering', () => {
|
||||
it('renders form with name, category, and notes fields', () => {
|
||||
const wrapper = mountForm()
|
||||
expect(getNameInput(wrapper).exists()).toBe(true)
|
||||
expect(getCategorySelect(wrapper).exists()).toBe(true)
|
||||
expect(getNotesTextarea(wrapper).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows "Créer" button in create mode', () => {
|
||||
const wrapper = mountForm({ mode: 'create' })
|
||||
expect(getSubmitButton(wrapper).text()).toBe('Créer')
|
||||
})
|
||||
|
||||
it('shows "Enregistrer" button in edit mode', () => {
|
||||
const wrapper = mountForm({ mode: 'edit' })
|
||||
expect(getSubmitButton(wrapper).text()).toBe('Enregistrer')
|
||||
})
|
||||
|
||||
it('populates form from initialData in edit mode', async () => {
|
||||
const wrapper = mountForm({
|
||||
mode: 'edit',
|
||||
initialData: {
|
||||
name: 'Existing Type',
|
||||
code: 'existing-type',
|
||||
category: 'COMPONENT',
|
||||
notes: 'Some notes',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect((getNameInput(wrapper).element as HTMLInputElement).value).toBe('Existing Type')
|
||||
expect((getNotesTextarea(wrapper).element as HTMLTextAreaElement).value).toBe('Some notes')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('validation', () => {
|
||||
it('shows error for name shorter than 2 characters', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('A')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('au moins 2 caractères')
|
||||
expect(wrapper.emitted('submit')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows error for name longer than 120 characters', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('A'.repeat(121))
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('ne peut pas dépasser 120')
|
||||
expect(wrapper.emitted('submit')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('submits with valid name', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Mon Type')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show error with valid name', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Valid Name')
|
||||
await submitForm(wrapper)
|
||||
|
||||
expect(wrapper.text()).not.toContain('au moins 2')
|
||||
expect(wrapper.text()).not.toContain('ne peut pas dépasser')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code generation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('code generation', () => {
|
||||
it('auto-generates code from name in create mode', async () => {
|
||||
const wrapper = mountForm({ mode: 'create' })
|
||||
await getNameInput(wrapper).setValue('Ma Catégorie')
|
||||
await nextTick()
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.code).toBe('ma-categorie')
|
||||
})
|
||||
|
||||
it('preserves initialData code in edit mode', async () => {
|
||||
const wrapper = mountForm({
|
||||
mode: 'edit',
|
||||
initialData: {
|
||||
name: 'Type',
|
||||
code: 'custom-code',
|
||||
category: 'COMPONENT',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.code).toBe('custom-code')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category-based structure editor rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('structure editor rendering', () => {
|
||||
it('renders ComponentModelStructureEditor for COMPONENT', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders PieceModelStructureEditor for PIECE', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PIECE' })
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders PieceModelStructureEditor for PRODUCT', () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
|
||||
// PRODUCT reuses PieceModelStructureEditor
|
||||
expect(wrapper.find('[data-testid="piece-editor"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="comp-editor"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category lock
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('category lock', () => {
|
||||
it('disables category select when lockCategory is true', () => {
|
||||
const wrapper = mountForm({ lockCategory: true })
|
||||
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables category select when lockCategory is false', () => {
|
||||
const wrapper = mountForm({ lockCategory: false })
|
||||
expect((getCategorySelect(wrapper).element as HTMLSelectElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restricted mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('restricted mode', () => {
|
||||
it('shows restricted mode message', () => {
|
||||
const wrapper = mountForm({
|
||||
restrictedMode: true,
|
||||
restrictedModeMessage: 'Mode restreint actif',
|
||||
})
|
||||
expect(wrapper.text()).toContain('Mode restreint actif')
|
||||
expect(wrapper.find('.alert-info').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show restricted mode message when not restricted', () => {
|
||||
const wrapper = mountForm({
|
||||
restrictedMode: false,
|
||||
})
|
||||
expect(wrapper.find('.alert-info').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables name input in restricted mode', () => {
|
||||
const wrapper = mountForm({ restrictedMode: true })
|
||||
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('submit disabled', () => {
|
||||
it('disables submit button when disableSubmit is true', () => {
|
||||
const wrapper = mountForm({ disableSubmit: true })
|
||||
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows warning alert when disableSubmit is true', () => {
|
||||
const wrapper = mountForm({
|
||||
disableSubmit: true,
|
||||
disableSubmitMessage: 'Cannot save now',
|
||||
})
|
||||
expect(wrapper.find('.alert-warning').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Cannot save now')
|
||||
})
|
||||
|
||||
it('does not show warning when disableSubmit is false', () => {
|
||||
const wrapper = mountForm({ disableSubmit: false })
|
||||
expect(wrapper.find('.alert-warning').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saving state
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('saving state', () => {
|
||||
it('disables submit button when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables cancel button when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect((getCancelButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows spinner when saving', () => {
|
||||
const wrapper = mountForm({ saving: true })
|
||||
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('cancel', () => {
|
||||
it('emits cancel on cancel button click', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getCancelButton(wrapper).trigger('click')
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit payload
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('submit payload', () => {
|
||||
it('emits payload with COMPONENT category and structure', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'COMPONENT' })
|
||||
await getNameInput(wrapper).setValue('Test Component')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('COMPONENT')
|
||||
expect(payload.name).toBe('Test Component')
|
||||
expect(payload.structure).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits payload with PIECE category', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PIECE' })
|
||||
await getNameInput(wrapper).setValue('Test Piece')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('PIECE')
|
||||
})
|
||||
|
||||
it('emits payload with PRODUCT category', async () => {
|
||||
const wrapper = mountForm({ initialCategory: 'PRODUCT' })
|
||||
await getNameInput(wrapper).setValue('Test Product')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.category).toBe('PRODUCT')
|
||||
})
|
||||
|
||||
it('includes trimmed notes in payload', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Test')
|
||||
await getNotesTextarea(wrapper).setValue(' Some notes ')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.notes).toBe('Some notes')
|
||||
})
|
||||
|
||||
it('omits empty notes from payload', async () => {
|
||||
const wrapper = mountForm()
|
||||
await getNameInput(wrapper).setValue('Test')
|
||||
await getNotesTextarea(wrapper).setValue('')
|
||||
await submitForm(wrapper)
|
||||
|
||||
const payload = (wrapper.emitted('submit')![0] as any[])[0]
|
||||
expect(payload.notes).toBeUndefined()
|
||||
})
|
||||
})
|
||||
396
tests/components/PieceModelStructureEditor.test.ts
Normal file
396
tests/components/PieceModelStructureEditor.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('~/composables/useProductTypes', () => ({
|
||||
useProductTypes: () => ({
|
||||
productTypes: ref([]),
|
||||
loadingProductTypes: ref(false),
|
||||
loadProductTypes: vi.fn().mockResolvedValue({ success: true }),
|
||||
createProductType: vi.fn(),
|
||||
updateProductType: vi.fn(),
|
||||
deleteProductType: vi.fn(),
|
||||
getProductTypes: () => [],
|
||||
isProductTypeLoading: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('~/shared/modelUtils', () => ({
|
||||
normalizePieceStructureForSave: (s: any) => s ?? { customFields: [] },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mountEditor(props: Record<string, any> = {}) {
|
||||
return mount(PieceModelStructureEditor, {
|
||||
props: {
|
||||
modelValue: { customFields: [], products: [] },
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getAddFieldButton(wrapper: VueWrapper) {
|
||||
// The second "Ajouter" button is for custom fields
|
||||
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
|
||||
return buttons[buttons.length - 1]
|
||||
}
|
||||
|
||||
function getAddProductButton(wrapper: VueWrapper) {
|
||||
// The first "Ajouter" button is for products
|
||||
const buttons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
|
||||
return buttons[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('empty state', () => {
|
||||
it('renders with no fields and shows empty message', () => {
|
||||
const wrapper = mountEditor()
|
||||
expect(wrapper.text()).toContain('Aucun champ personnalisé')
|
||||
})
|
||||
|
||||
it('renders with no products and shows empty message', () => {
|
||||
const wrapper = mountEditor()
|
||||
expect(wrapper.text()).toContain('Aucun produit défini')
|
||||
})
|
||||
|
||||
it('shows add field button', () => {
|
||||
const wrapper = mountEditor()
|
||||
expect(getAddFieldButton(wrapper).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add custom field
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('add custom field', () => {
|
||||
it('adds a new empty field on button click', async () => {
|
||||
const wrapper = mountEditor()
|
||||
await getAddFieldButton(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const nameInputs = wrapper.findAll('input[type="text"]')
|
||||
expect(nameInputs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(wrapper.text()).not.toContain('Aucun champ personnalisé')
|
||||
})
|
||||
|
||||
it('new field has type "text" by default', async () => {
|
||||
const wrapper = mountEditor()
|
||||
await getAddFieldButton(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const selects = wrapper.findAll('select')
|
||||
// The last select should be the type select for the new field
|
||||
const typeSelect = selects[selects.length - 1]
|
||||
expect((typeSelect.element as HTMLSelectElement).value).toBe('text')
|
||||
})
|
||||
|
||||
it('emits update:modelValue after adding field', async () => {
|
||||
const wrapper = mountEditor()
|
||||
await getAddFieldButton(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove custom field
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('remove custom field', () => {
|
||||
it('removes field on delete button click', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Find the delete button (btn-error)
|
||||
const deleteBtn = wrapper.find('button.btn-error')
|
||||
expect(deleteBtn.exists()).toBe(true)
|
||||
|
||||
await deleteBtn.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Aucun champ personnalisé')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field name editing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('field name editing', () => {
|
||||
it('updates field name on input', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Old Name', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('input[type="text"]')
|
||||
await nameInput.setValue('New Name')
|
||||
await nextTick()
|
||||
|
||||
const events = wrapper.emitted('update:modelValue')
|
||||
expect(events).toBeTruthy()
|
||||
const lastPayload = events![events!.length - 1][0] as any
|
||||
expect(lastPayload.customFields[0].name).toBe('New Name')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field type change
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('field type change', () => {
|
||||
it('changes field type via select', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Size', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Find the type select (not the product type select)
|
||||
const selects = wrapper.findAll('select')
|
||||
const typeSelect = selects[selects.length - 1]
|
||||
await typeSelect.setValue('number')
|
||||
await nextTick()
|
||||
|
||||
const events = wrapper.emitted('update:modelValue')
|
||||
expect(events).toBeTruthy()
|
||||
const lastPayload = events![events!.length - 1][0] as any
|
||||
expect(lastPayload.customFields[0].type).toBe('number')
|
||||
})
|
||||
|
||||
it('shows options textarea for select type', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: 'Red\nBlue' }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides options textarea for non-select types', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Weight', type: 'number', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Required checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('required checkbox', () => {
|
||||
it('toggles required on checkbox change', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Test', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
await nextTick()
|
||||
|
||||
const events = wrapper.emitted('update:modelValue')
|
||||
expect(events).toBeTruthy()
|
||||
const lastPayload = events![events!.length - 1][0] as any
|
||||
expect(lastPayload.customFields[0].required).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restricted mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('restricted mode', () => {
|
||||
it('allows editing name of pre-existing field', () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('input[type="text"]')
|
||||
expect((nameInput.element as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('disables type select for pre-existing field', () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const selects = wrapper.findAll('select')
|
||||
const typeSelect = selects[selects.length - 1]
|
||||
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables required checkbox for pre-existing field', () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button for pre-existing field', () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
// btn-error should not exist for locked fields
|
||||
const deleteBtn = wrapper.find('button.btn-error')
|
||||
expect(deleteBtn.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('allows full editing of newly added field', async () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: {
|
||||
customFields: [],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
await getAddFieldButton(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// New field should have an editable type select (not disabled)
|
||||
const selects = wrapper.findAll('select')
|
||||
const typeSelect = selects[selects.length - 1]
|
||||
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false)
|
||||
|
||||
// Delete button should exist for new field
|
||||
const deleteBtn = wrapper.find('button.btn-error')
|
||||
expect(deleteBtn.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides product add button in restricted mode', () => {
|
||||
const wrapper = mountEditor({
|
||||
restrictedMode: true,
|
||||
modelValue: { customFields: [], products: [] },
|
||||
})
|
||||
|
||||
const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
|
||||
// Only the "add field" button should be visible, not the product one
|
||||
expect(addButtons.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add product
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('add product', () => {
|
||||
it('adds a product entry on button click', async () => {
|
||||
const wrapper = mountEditor()
|
||||
await getAddProductButton(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Aucun produit défini')
|
||||
// Should have a product type select
|
||||
expect(wrapper.findAll('select').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('options parsing', () => {
|
||||
it('parses multiline options text into array', async () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [{ name: 'Color', type: 'select', required: false, orderIndex: 0, optionsText: '' }],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Red\nGreen\nBlue')
|
||||
await nextTick()
|
||||
|
||||
const events = wrapper.emitted('update:modelValue')
|
||||
expect(events).toBeTruthy()
|
||||
const lastPayload = events![events!.length - 1][0] as any
|
||||
expect(lastPayload.customFields[0].options).toEqual(['Red', 'Green', 'Blue'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hydration from modelValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('hydration', () => {
|
||||
it('renders existing fields from modelValue', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [
|
||||
{ name: 'Weight', type: 'number', required: true, orderIndex: 0 },
|
||||
{ name: 'Color', type: 'text', required: false, orderIndex: 1 },
|
||||
],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const nameInputs = wrapper.findAll('input[type="text"]')
|
||||
expect(nameInputs.length).toBe(2)
|
||||
expect((nameInputs[0].element as HTMLInputElement).value).toBe('Weight')
|
||||
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Color')
|
||||
})
|
||||
|
||||
it('sorts fields by orderIndex', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: {
|
||||
customFields: [
|
||||
{ name: 'Second', type: 'text', required: false, orderIndex: 1 },
|
||||
{ name: 'First', type: 'text', required: false, orderIndex: 0 },
|
||||
],
|
||||
products: [],
|
||||
},
|
||||
})
|
||||
|
||||
const nameInputs = wrapper.findAll('input[type="text"]')
|
||||
expect((nameInputs[0].element as HTMLInputElement).value).toBe('First')
|
||||
expect((nameInputs[1].element as HTMLInputElement).value).toBe('Second')
|
||||
})
|
||||
})
|
||||
269
tests/composables/useCategoryEditGuard.test.ts
Normal file
269
tests/composables/useCategoryEditGuard.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockShowInfo = vi.fn()
|
||||
|
||||
vi.mock('~/composables/useApi', () => ({
|
||||
useApi: () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
apiCall: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('~/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showInfo: mockShowInfo,
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
toasts: { value: [] },
|
||||
clearAll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const GUARD_CONFIG = {
|
||||
endpoint: '/composants',
|
||||
filterKey: 'typeComposant',
|
||||
labels: {
|
||||
singular: 'composant',
|
||||
plural: 'composants',
|
||||
verifying: 'Vérification des composants liés en cours…',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('initial state', () => {
|
||||
it('has linkedCount 0 and restrictedMode false', () => {
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
expect(guard.linkedCount.value).toBe(0)
|
||||
expect(guard.isRestrictedMode.value).toBe(false)
|
||||
expect(guard.isSubmitBlocked.value).toBe(false)
|
||||
expect(guard.linkedLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('has empty messages when no linked items', () => {
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
expect(guard.restrictedModeMessage.value).toBe('')
|
||||
expect(guard.submitBlockMessage.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadLinkedCount
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('loadLinkedCount', () => {
|
||||
it('sets linkedCount from API totalItems', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 5, member: [] },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(5)
|
||||
expect(guard.isRestrictedMode.value).toBe(true)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/composants?'),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets linkedCount 0 when API returns 0 items', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 0, member: [] },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(0)
|
||||
expect(guard.isRestrictedMode.value).toBe(false)
|
||||
})
|
||||
|
||||
it('extracts totalItems from hydra:totalItems format', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('falls back to member.length when no totalItems', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { member: [{}, {}] },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('falls back to hydra:member.length', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { 'hydra:member': [{}] },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('sets linkedCount 0 on API failure', async () => {
|
||||
mockGet.mockResolvedValue({ success: false })
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(0)
|
||||
expect(guard.isRestrictedMode.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets linkedCount 0 on exception', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('sends correct filter parameters', async () => {
|
||||
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('abc-123')
|
||||
|
||||
const callUrl = mockGet.mock.calls[0][0] as string
|
||||
expect(callUrl).toContain('itemsPerPage=1')
|
||||
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restrictedModeMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('restrictedModeMessage', () => {
|
||||
it('shows singular message for 1 linked item', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 1 },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.restrictedModeMessage.value).toContain('1 composant')
|
||||
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
|
||||
})
|
||||
|
||||
it('shows plural message for multiple linked items', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 5 },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.restrictedModeMessage.value).toContain('5 composants')
|
||||
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
|
||||
})
|
||||
|
||||
it('uses custom labels from config', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 3 },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard({
|
||||
endpoint: '/pieces',
|
||||
filterKey: 'typePiece',
|
||||
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
|
||||
})
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isSubmitBlocked & submitBlockMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('submit blocking', () => {
|
||||
it('blocks submit during loading', () => {
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
// Simulate loading state by starting a load without awaiting
|
||||
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
|
||||
guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.linkedLoading.value).toBe(true)
|
||||
expect(guard.isSubmitBlocked.value).toBe(true)
|
||||
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
|
||||
})
|
||||
|
||||
it('unblocks submit after loading completes', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 5 },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.isSubmitBlocked.value).toBe(false)
|
||||
expect(guard.submitBlockMessage.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// guardSubmitOrNotify
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('guardSubmitOrNotify', () => {
|
||||
it('returns false when not blocked', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: { totalItems: 0 },
|
||||
})
|
||||
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
await guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.guardSubmitOrNotify()).toBe(false)
|
||||
expect(mockShowInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns true and shows info when blocked', () => {
|
||||
const guard = useCategoryEditGuard(GUARD_CONFIG)
|
||||
// Simulate loading
|
||||
mockGet.mockReturnValue(new Promise(() => {}))
|
||||
guard.loadLinkedCount('mt-1')
|
||||
|
||||
expect(guard.guardSubmitOrNotify()).toBe(true)
|
||||
expect(mockShowInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
412
tests/composables/useEntityTypes.test.ts
Normal file
412
tests/composables/useEntityTypes.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useEntityTypes, invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('~/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
showInfo: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
toasts: { value: [] },
|
||||
clearAll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockListModelTypes = vi.fn()
|
||||
const mockCreateModelType = vi.fn()
|
||||
const mockUpdateModelType = vi.fn()
|
||||
const mockDeleteModelType = vi.fn()
|
||||
|
||||
vi.mock('~/services/modelTypes', () => ({
|
||||
listModelTypes: (...args: any[]) => mockListModelTypes(...args),
|
||||
createModelType: (...args: any[]) => mockCreateModelType(...args),
|
||||
updateModelType: (...args: any[]) => mockUpdateModelType(...args),
|
||||
deleteModelType: (...args: any[]) => mockDeleteModelType(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeItem = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
code: name.toLowerCase(),
|
||||
category: 'COMPONENT' as const,
|
||||
structure: null,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
notes: null,
|
||||
description: null,
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
// Reset singleton state via public APIs
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
comp.types.value = []
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
piece.types.value = []
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
|
||||
const product = useEntityTypes({ category: 'PRODUCT', label: 'produit' })
|
||||
product.types.value = []
|
||||
invalidateEntityTypeCache('PRODUCT')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetState()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadTypes
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('loadTypes', () => {
|
||||
it('fetches from API and stores in types', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Type A'), fakeItem('2', 'Type B')],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await loadTypes()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(2)
|
||||
expect(types.value[0].name).toBe('Type A')
|
||||
expect(mockListModelTypes).toHaveBeenCalledOnce()
|
||||
expect(mockListModelTypes).toHaveBeenCalledWith({
|
||||
category: 'COMPONENT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns cached data on second call without force', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Cached')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call — should return cache
|
||||
const result = await loadTypes()
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1) // NOT called again
|
||||
})
|
||||
|
||||
it('refetches with force: true', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('1', 'Old')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('1', 'New')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(types.value[0].name).toBe('Old')
|
||||
|
||||
await loadTypes({ force: true })
|
||||
expect(types.value[0].name).toBe('New')
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets loading during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
mockListModelTypes.mockReturnValue(new Promise((resolve) => { resolvePromise = resolve }))
|
||||
|
||||
const { loadTypes, loading } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
expect(loading.value).toBe(false)
|
||||
|
||||
const promise = loadTypes()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolvePromise!({ items: [], total: 0, offset: 0, limit: 200 })
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error on API failure', async () => {
|
||||
mockListModelTypes.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await loadTypes()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Network error')
|
||||
expect(types.value).toEqual([])
|
||||
expect(mockShowError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Network error'),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes items with description fallback', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [{ ...fakeItem('1', 'Test'), notes: 'From notes', description: null }],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
expect(types.value[0].description).toBe('From notes')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createType', () => {
|
||||
it('creates and pushes to types array', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Created'))
|
||||
|
||||
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await createType({ name: 'Created' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].name).toBe('Created')
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Created'))
|
||||
})
|
||||
|
||||
it('sends correct payload with auto-generated code', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Ma Catégorie'))
|
||||
|
||||
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await createType({ name: 'Ma Catégorie' })
|
||||
|
||||
expect(mockCreateModelType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Ma Catégorie',
|
||||
code: 'ma-categorie',
|
||||
category: 'COMPONENT',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses provided code if available', async () => {
|
||||
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Test'))
|
||||
|
||||
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await createType({ name: 'Test', code: 'custom-code' })
|
||||
|
||||
expect(mockCreateModelType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'custom-code' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error on API failure without modifying types', async () => {
|
||||
mockCreateModelType.mockRejectedValue(new Error('Create failed'))
|
||||
|
||||
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const result = await createType({ name: 'Fail' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value).toEqual([])
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('updateType', () => {
|
||||
it('updates the existing item in types array', async () => {
|
||||
// Pre-populate
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Original')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockUpdateModelType.mockResolvedValue(fakeItem('1', 'Updated'))
|
||||
const result = await updateType('1', { name: 'Updated' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].name).toBe('Updated')
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Updated'))
|
||||
})
|
||||
|
||||
it('shows error on API failure without modifying types', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Original')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockUpdateModelType.mockRejectedValue(new Error('Update failed'))
|
||||
const result = await updateType('1', { name: 'Bad' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value[0].name).toBe('Original')
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('deleteType', () => {
|
||||
it('removes item from types array', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'A'), fakeItem('2', 'B')],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(types.value).toHaveLength(2)
|
||||
|
||||
mockDeleteModelType.mockResolvedValue(undefined)
|
||||
const result = await deleteType('1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(types.value[0].id).toBe('2')
|
||||
expect(mockShowSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error on API failure without removing item', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Keep')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
|
||||
mockDeleteModelType.mockRejectedValue(new Error('Delete failed'))
|
||||
const result = await deleteType('1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(types.value).toHaveLength(1)
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// invalidateEntityTypeCache
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('invalidateEntityTypeCache', () => {
|
||||
it('forces next loadTypes to refetch', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Cached')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Without invalidation, wouldn't refetch
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Fresh')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const { loadTypes: loadAgain, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await loadAgain()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
expect(types.value[0].name).toBe('Fresh')
|
||||
})
|
||||
|
||||
it('does not crash for unknown category', () => {
|
||||
expect(() => invalidateEntityTypeCache('COMPONENT')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton per category
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('singleton per category', () => {
|
||||
it('shares state between same category calls', async () => {
|
||||
mockListModelTypes.mockResolvedValue({
|
||||
items: [fakeItem('1', 'Shared')],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const a = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
await a.loadTypes()
|
||||
|
||||
const b = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
expect(b.types.value).toHaveLength(1)
|
||||
expect(b.types.value[0].name).toBe('Shared')
|
||||
})
|
||||
|
||||
it('isolates state between different categories', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Component')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
|
||||
await comp.loadTypes()
|
||||
await piece.loadTypes()
|
||||
|
||||
expect(comp.types.value).toHaveLength(1)
|
||||
expect(comp.types.value[0].name).toBe('Component')
|
||||
expect(piece.types.value).toHaveLength(1)
|
||||
expect(piece.types.value[0].name).toBe('Piece')
|
||||
})
|
||||
|
||||
it('invalidateEntityTypeCache only affects target category', async () => {
|
||||
mockListModelTypes
|
||||
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Comp')], total: 1, offset: 0, limit: 200 })
|
||||
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
|
||||
|
||||
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
|
||||
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
|
||||
await comp.loadTypes()
|
||||
await piece.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Invalidate only COMPONENT
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
|
||||
// PIECE should still use cache
|
||||
await piece.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(2) // No extra call
|
||||
|
||||
// COMPONENT should refetch
|
||||
mockListModelTypes.mockResolvedValueOnce({ items: [fakeItem('c1', 'Refreshed')], total: 1, offset: 0, limit: 200 })
|
||||
await comp.loadTypes()
|
||||
expect(mockListModelTypes).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
228
tests/services/modelTypes.test.ts
Normal file
228
tests/services/modelTypes.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Import AFTER mocking
|
||||
import {
|
||||
createModelType,
|
||||
updateModelType,
|
||||
deleteModelType,
|
||||
getModelType,
|
||||
type ModelType,
|
||||
type ModelTypePayload,
|
||||
} from '~/services/modelTypes'
|
||||
|
||||
// The service uses useRequestFetch from #imports (explicit import)
|
||||
// AND useRuntimeConfig as a Nuxt auto-import (bare global).
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
// Mock the explicit #imports module
|
||||
vi.mock('#imports', () => ({
|
||||
useRuntimeConfig: () => ({
|
||||
public: { apiBaseUrl: 'http://test-api:8081/api' },
|
||||
}),
|
||||
useRequestFetch: () => mockFetch,
|
||||
}))
|
||||
|
||||
// Also stub the global auto-import (Nuxt makes these globally available)
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
public: { apiBaseUrl: 'http://test-api:8081/api' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeModelType = (overrides: Partial<ModelType> = {}): ModelType => ({
|
||||
id: 'mt-1',
|
||||
name: 'Test Type',
|
||||
code: 'test-type',
|
||||
category: 'COMPONENT',
|
||||
structure: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeModelType (via getModelType)', () => {
|
||||
it('maps componentSkeleton to structure for COMPONENT', async () => {
|
||||
const skeleton = { customFields: [{ name: 'Weight' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'COMPONENT',
|
||||
structure: null,
|
||||
componentSkeleton: skeleton as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(skeleton)
|
||||
})
|
||||
|
||||
it('maps pieceSkeleton to structure for PIECE', async () => {
|
||||
const skeleton = { customFields: [{ name: 'Size' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'PIECE',
|
||||
structure: null,
|
||||
pieceSkeleton: skeleton as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(skeleton)
|
||||
})
|
||||
|
||||
it('maps productSkeleton to structure for PRODUCT', async () => {
|
||||
const skeleton = { customFields: [{ name: 'Brand' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'PRODUCT',
|
||||
structure: null,
|
||||
productSkeleton: skeleton as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(skeleton)
|
||||
})
|
||||
|
||||
it('does not override existing structure', async () => {
|
||||
const existing = { customFields: [{ name: 'Existing' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({
|
||||
category: 'COMPONENT',
|
||||
structure: existing as any,
|
||||
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
|
||||
}))
|
||||
|
||||
const result = await getModelType('mt-1')
|
||||
expect(result.structure).toEqual(existing)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createModelType — maps structure to skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createModelType', () => {
|
||||
it('sends POST with componentSkeleton for COMPONENT', async () => {
|
||||
const structure = { customFields: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
const payload: ModelTypePayload = {
|
||||
name: 'New Type',
|
||||
code: 'new-type',
|
||||
category: 'COMPONENT',
|
||||
structure: structure as any,
|
||||
}
|
||||
|
||||
await createModelType(payload)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.body.componentSkeleton).toEqual(structure)
|
||||
expect(options.body.structure).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends POST with pieceSkeleton for PIECE', async () => {
|
||||
const structure = { customFields: [], products: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
|
||||
|
||||
await createModelType({
|
||||
name: 'Piece Type',
|
||||
code: 'piece-type',
|
||||
category: 'PIECE',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.pieceSkeleton).toEqual(structure)
|
||||
expect(options.body.structure).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends POST with productSkeleton for PRODUCT', async () => {
|
||||
const structure = { customFields: [] }
|
||||
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
|
||||
|
||||
await createModelType({
|
||||
name: 'Product Type',
|
||||
code: 'product-type',
|
||||
category: 'PRODUCT',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.productSkeleton).toEqual(structure)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateModelType — maps structure to skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('updateModelType', () => {
|
||||
it('sends PATCH with correct endpoint and skeleton', async () => {
|
||||
const structure = { customFields: [{ name: 'Updated' }] }
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
await updateModelType('mt-1', {
|
||||
name: 'Updated',
|
||||
code: 'updated',
|
||||
category: 'COMPONENT',
|
||||
structure: structure as any,
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-1')
|
||||
expect(options.method).toBe('PATCH')
|
||||
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
|
||||
expect(options.body.componentSkeleton).toEqual(structure)
|
||||
})
|
||||
|
||||
it('sends payload without skeleton when no structure', async () => {
|
||||
mockFetch.mockResolvedValue(fakeModelType())
|
||||
|
||||
await updateModelType('mt-1', {
|
||||
name: 'Just Name',
|
||||
code: 'just-name',
|
||||
category: 'COMPONENT',
|
||||
})
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.body.componentSkeleton).toBeUndefined()
|
||||
expect(options.body.name).toBe('Just Name')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteModelType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('deleteModelType', () => {
|
||||
it('sends DELETE to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValue(undefined)
|
||||
|
||||
await deleteModelType('mt-42')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-42')
|
||||
expect(options.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getModelType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getModelType', () => {
|
||||
it('sends GET to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValue(fakeModelType({ id: 'mt-99' }))
|
||||
|
||||
const result = await getModelType('mt-99')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [endpoint, options] = mockFetch.mock.calls[0]
|
||||
expect(endpoint).toBe('/model_types/mt-99')
|
||||
expect(options.method).toBe('GET')
|
||||
expect(result.id).toBe('mt-99')
|
||||
})
|
||||
})
|
||||
508
tests/shared/customFieldFormUtils.test.ts
Normal file
508
tests/shared/customFieldFormUtils.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toFieldString,
|
||||
fieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveRequiredFlag,
|
||||
resolveOptions,
|
||||
resolveDefaultValue,
|
||||
formatDefaultValue,
|
||||
normalizeCustomField,
|
||||
normalizeCustomFieldInputs,
|
||||
extractStoredCustomFieldValue,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
shouldPersistField,
|
||||
formatValueForPersistence,
|
||||
buildCustomFieldMetadata,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toFieldString
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('toFieldString', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(toFieldString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(toFieldString(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('returns string as-is', () => {
|
||||
expect(toFieldString('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(toFieldString(42)).toBe('42')
|
||||
})
|
||||
|
||||
it('converts boolean to string', () => {
|
||||
expect(toFieldString(true)).toBe('true')
|
||||
expect(toFieldString(false)).toBe('false')
|
||||
})
|
||||
|
||||
it('returns empty string for objects', () => {
|
||||
expect(toFieldString({ foo: 'bar' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fieldKey
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('fieldKey', () => {
|
||||
it('prefers customFieldValueId', () => {
|
||||
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('falls back to id', () => {
|
||||
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
|
||||
expect(fieldKey(field, 0)).toBe('id-1')
|
||||
})
|
||||
|
||||
it('falls back to name-index', () => {
|
||||
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
|
||||
expect(fieldKey(field, 3)).toBe('weight-3')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldName
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldName', () => {
|
||||
it('resolves from name property', () => {
|
||||
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('falls back to key', () => {
|
||||
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
|
||||
})
|
||||
|
||||
it('falls back to label', () => {
|
||||
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
|
||||
})
|
||||
|
||||
it('returns empty string for empty object', () => {
|
||||
expect(resolveFieldName({})).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(resolveFieldName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFieldType
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveFieldType', () => {
|
||||
it('resolves valid type', () => {
|
||||
expect(resolveFieldType({ type: 'number' })).toBe('number')
|
||||
})
|
||||
|
||||
it('resolves case-insensitive', () => {
|
||||
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
|
||||
})
|
||||
|
||||
it('falls back to text for unknown type', () => {
|
||||
expect(resolveFieldType({ type: 'blob' })).toBe('text')
|
||||
})
|
||||
|
||||
it('resolves nested value.type', () => {
|
||||
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
|
||||
})
|
||||
|
||||
it('returns text for missing type', () => {
|
||||
expect(resolveFieldType({})).toBe('text')
|
||||
})
|
||||
|
||||
it('handles all allowed types', () => {
|
||||
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
|
||||
expect(resolveFieldType({ type })).toBe(type)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRequiredFlag
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveRequiredFlag', () => {
|
||||
it('resolves boolean true', () => {
|
||||
expect(resolveRequiredFlag({ required: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves boolean false', () => {
|
||||
expect(resolveRequiredFlag({ required: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves nested value.required', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "true"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves string "1"', () => {
|
||||
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false', () => {
|
||||
expect(resolveRequiredFlag({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveOptions', () => {
|
||||
it('resolves array of strings', () => {
|
||||
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with value key', () => {
|
||||
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('resolves array of objects with label key', () => {
|
||||
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
|
||||
})
|
||||
|
||||
it('falls back to value.options', () => {
|
||||
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
|
||||
})
|
||||
|
||||
it('falls back to value.choices', () => {
|
||||
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
|
||||
})
|
||||
|
||||
it('returns empty array for no options', () => {
|
||||
expect(resolveOptions({})).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out empty strings', () => {
|
||||
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('filters out null values', () => {
|
||||
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveDefaultValue', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(resolveDefaultValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves defaultValue', () => {
|
||||
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
|
||||
})
|
||||
|
||||
it('resolves value (non-object)', () => {
|
||||
expect(resolveDefaultValue({ value: 42 })).toBe(42)
|
||||
})
|
||||
|
||||
it('resolves nested value.defaultValue', () => {
|
||||
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
|
||||
})
|
||||
|
||||
it('returns null when nothing found', () => {
|
||||
expect(resolveDefaultValue({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDefaultValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDefaultValue', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatDefaultValue('text', null)).toBe('')
|
||||
})
|
||||
|
||||
it('converts number to string', () => {
|
||||
expect(formatDefaultValue('number', 42)).toBe('42')
|
||||
})
|
||||
|
||||
it('handles boolean type with true', () => {
|
||||
expect(formatDefaultValue('boolean', 'true')).toBe('true')
|
||||
expect(formatDefaultValue('boolean', true)).toBe('true')
|
||||
expect(formatDefaultValue('boolean', '1')).toBe('true')
|
||||
})
|
||||
|
||||
it('handles boolean type with false', () => {
|
||||
expect(formatDefaultValue('boolean', 'false')).toBe('false')
|
||||
expect(formatDefaultValue('boolean', false)).toBe('false')
|
||||
expect(formatDefaultValue('boolean', '0')).toBe('false')
|
||||
})
|
||||
|
||||
it('unwraps nested defaultValue object', () => {
|
||||
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomField
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomField', () => {
|
||||
it('normalizes a complete field', () => {
|
||||
const result = normalizeCustomField({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
orderIndex: 2,
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: 'cf-1',
|
||||
name: 'Weight',
|
||||
type: 'number',
|
||||
required: true,
|
||||
options: [],
|
||||
value: '',
|
||||
customFieldId: 'cf-1',
|
||||
customFieldValueId: null,
|
||||
orderIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(normalizeCustomField(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for field without name', () => {
|
||||
expect(normalizeCustomField({ type: 'text' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses fallback index', () => {
|
||||
const result = normalizeCustomField({ name: 'Test' }, 5)
|
||||
expect(result?.orderIndex).toBe(5)
|
||||
})
|
||||
|
||||
it('defaults type to text', () => {
|
||||
const result = normalizeCustomField({ name: 'Field' })
|
||||
expect(result?.type).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('normalizeCustomFieldInputs', () => {
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(normalizeCustomFieldInputs(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for structure without customFields', () => {
|
||||
expect(normalizeCustomFieldInputs({})).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes and sorts fields by orderIndex', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [
|
||||
{ name: 'B', orderIndex: 2 },
|
||||
{ name: 'A', orderIndex: 1 },
|
||||
],
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('A')
|
||||
expect(result[1].name).toBe('B')
|
||||
})
|
||||
|
||||
it('filters out invalid fields', () => {
|
||||
const result = normalizeCustomFieldInputs({
|
||||
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('Valid')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractStoredCustomFieldValue
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('extractStoredCustomFieldValue', () => {
|
||||
it('returns string directly', () => {
|
||||
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns number directly', () => {
|
||||
expect(extractStoredCustomFieldValue(42)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(extractStoredCustomFieldValue(null)).toBe('')
|
||||
})
|
||||
|
||||
it('extracts .value from object', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
|
||||
})
|
||||
|
||||
it('extracts nested .value.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
|
||||
})
|
||||
|
||||
it('extracts customFieldValue.value', () => {
|
||||
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldInputs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldInputs', () => {
|
||||
const definitions = {
|
||||
customFields: [
|
||||
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
|
||||
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
it('builds inputs from definitions without values', () => {
|
||||
const result = buildCustomFieldInputs(definitions, null)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].name).toBe('Weight')
|
||||
expect(result[0].value).toBe('')
|
||||
expect(result[1].name).toBe('Color')
|
||||
})
|
||||
|
||||
it('merges stored values by id', () => {
|
||||
const values = [
|
||||
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[0].value).toBe('42')
|
||||
expect(result[0].customFieldValueId).toBe('cfv-1')
|
||||
})
|
||||
|
||||
it('merges stored values by name fallback', () => {
|
||||
const values = [
|
||||
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
|
||||
]
|
||||
const result = buildCustomFieldInputs(definitions, values)
|
||||
expect(result[1].value).toBe('Blue')
|
||||
})
|
||||
|
||||
it('returns empty array for null structure', () => {
|
||||
expect(buildCustomFieldInputs(null, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requiredCustomFieldsFilled
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('requiredCustomFieldsFilled', () => {
|
||||
it('returns true when all required fields are filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when required field is empty', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for non-required empty field', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "false" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean "true" as filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats boolean empty as not filled', () => {
|
||||
const inputs: CustomFieldInput[] = [
|
||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
||||
]
|
||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for empty array', () => {
|
||||
expect(requiredCustomFieldsFilled([])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldPersistField & formatValueForPersistence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('shouldPersistField', () => {
|
||||
it('returns true for non-empty text field', () => {
|
||||
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for empty text field', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for boolean "true"', () => {
|
||||
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for boolean "false"', () => {
|
||||
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for boolean empty', () => {
|
||||
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForPersistence', () => {
|
||||
it('trims text value', () => {
|
||||
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns "true" for boolean true', () => {
|
||||
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
|
||||
})
|
||||
|
||||
it('returns "false" for boolean non-true', () => {
|
||||
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCustomFieldMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildCustomFieldMetadata', () => {
|
||||
it('builds metadata from field', () => {
|
||||
const field: CustomFieldInput = {
|
||||
id: null, name: 'Color', type: 'select', required: true,
|
||||
options: ['Red', 'Blue'], value: 'Red',
|
||||
customFieldId: null, customFieldValueId: null, orderIndex: 0,
|
||||
}
|
||||
expect(buildCustomFieldMetadata(field)).toEqual({
|
||||
customFieldName: 'Color',
|
||||
customFieldType: 'select',
|
||||
customFieldRequired: true,
|
||||
customFieldOptions: ['Red', 'Blue'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user