Compare commits

..

20 Commits

Author SHA1 Message Date
gitea-actions
b304cf6684 chore : bump version to v1.9.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 36s
2026-04-06 15:12:40 +00:00
0fe7f3131e fix(model-type) : retirer l'éditeur de structure produit inutilisé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Le PieceModelStructureEditor affiché pour les catégories PRODUCT ne
fonctionnait plus et n'est plus utilisé.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
a6bbcaf6d1 fix(custom-fields) : masquer les champs machineContextOnly hors vue machine
Ajoute context: 'standalone' aux appels useCustomFieldInputs dans les
vues composant, pièce et produit (création et édition) pour filtrer
les champs perso réservés au contexte machine.

Exclut également ces champs de la formule de référence automatique
dans le ReferenceFormulaBuilder des catégories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
9f2e1da6ec fix(composant) : rendre les slots de structure optionnels à la création
Les emplacements pièces, produits et sous-composants du squelette ne
bloquent plus la soumission du formulaire de création de composant.
Les slots vides restent visibles en consultation avec l'indicateur rouge
« manquant » et peuvent être remplis ultérieurement en édition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
gitea-actions
7962576eec chore : bump version to v1.9.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 37s
2026-04-06 14:54:20 +00:00
7d98c1598c fix(deps) : update composer.lock with symfony/mime
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:09 +02:00
gitea-actions
4772f057a3 chore : bump version to v1.9.19
Some checks failed
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Failing after 9s
2026-04-06 14:52:56 +00:00
6680423e64 fix(deps) : add symfony/mime as explicit dependency
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Was previously pulled as transitive dependency but disappeared after
composer update, causing 500 errors on document upload in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
2c2de8bc00 test(machine-detail) : add hierarchy loading and override data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
150aceac24 test(piece-edit,documents) : add productIds sync, error paths, and document CRUD tests 2026-04-06 16:52:42 +02:00
972f30e772 test(component-create) : add structure, error path, and null handling tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
8af68c9628 test(component-edit) : add document, error path, and null handling tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
eb68336723 test(machine-custom-fields) : add checkbox and data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
eeba229574 test(piece-edit) : add edit flow and product slot data integrity tests 2026-04-06 16:52:41 +02:00
4454bbea3d test(component-edit) : add edit flow and slot data integrity tests 2026-04-06 16:52:41 +02:00
1e40334e11 test(component-create) : add creation flow data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
83c75ecf69 test(crud) : add CRUD cache data integrity tests for products, composants, pieces
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
b54739f6de test(custom-fields) : add data integrity tests for all field types 2026-04-06 16:52:41 +02:00
82cbeb91a5 test(constructeur-links) : add sync algorithm data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
e70c66e215 test(fixtures) : add shared mock data for data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
22 changed files with 5449 additions and 39 deletions

View File

@@ -23,6 +23,7 @@
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",

175
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
"content-hash": "f94dc3c05e9ba6be99c510aad3d17182",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5341,6 +5341,92 @@
],
"time": "2026-03-04T16:39:24+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66",
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
@@ -5567,6 +5653,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.18'
app.version: '1.9.21'

View File

@@ -95,16 +95,6 @@
<PieceModelStructureEditor v-model="pieceStructure" />
</div>
<div
v-else
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template>
</section>
@@ -194,15 +184,16 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
})
const formulaBuilderCustomFields = computed(() => {
let fields: any[] = []
if (form.category === 'PIECE') {
const fields = pieceStructure.value?.customFields
return Array.isArray(fields) ? fields : []
const raw = pieceStructure.value?.customFields
fields = Array.isArray(raw) ? raw : []
}
if (form.category === 'COMPONENT') {
const fields = componentStructure.value?.customFields
return Array.isArray(fields) ? fields : []
else if (form.category === 'COMPONENT') {
const raw = componentStructure.value?.customFields
fields = Array.isArray(raw) ? raw : []
}
return []
return fields.filter((f: any) => !f.machineContextOnly)
})
const extractFormulaFields = (formula: string | null | undefined): string[] => {

View File

@@ -34,7 +34,6 @@ import {
import {
hasAssignments,
initializeStructureAssignments,
isAssignmentNodeComplete,
serializeStructureAssignments,
} from '~/shared/utils/structureAssignmentHelpers'
import type { ComponentModelStructure } from '~/shared/types/inventory'
@@ -152,24 +151,14 @@ export function useComponentCreate() {
values: computed(() => []),
entityType: 'composant',
entityId: createdComponentId,
context: 'standalone',
})
const structureHasRequirements = computed(() =>
hasAssignments(structureAssignments.value),
)
const structureSelectionsComplete = computed(() => {
if (!structureHasRequirements.value) {
return true
}
if (structureDataLoading.value) {
return false
}
if (!structureAssignments.value) {
return false
}
return isAssignmentNodeComplete(structureAssignments.value, true)
})
const structureSelectionsComplete = computed(() => true)
const canSubmit = computed(() => Boolean(
canEdit.value
@@ -307,11 +296,6 @@ export function useComponentCreate() {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
return
}
const serializedStructure = structureHasRequirements.value
? serializeStructureAssignments(structureAssignments.value)
: null
@@ -414,6 +398,7 @@ export function useComponentCreate() {
structureSelectionsComplete,
canEdit,
canSubmit,
requiredCustomFieldsFilled,
// Functions
typeOptionLabel,

View File

@@ -209,6 +209,7 @@ export function useComponentEdit(componentId: string) {
values: computed(() => component.value?.customFieldValues ?? []),
entityType: 'composant',
entityId: computed(() => component.value?.id ?? null),
context: 'standalone',
onValueCreated: (newValue) => {
if (component.value && Array.isArray(component.value.customFieldValues)) {
component.value.customFieldValues.push(newValue)
@@ -556,6 +557,7 @@ export function useComponentEdit(componentId: string) {
originalConstructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
requiredCustomFieldsFilled,
historyFieldLabels,
// Computed

View File

@@ -99,6 +99,7 @@ export function usePieceEdit(pieceId: string) {
values: computed(() => piece.value?.customFieldValues ?? []),
entityType: 'piece',
entityId: computed(() => piece.value?.id ?? null),
context: 'standalone',
onValueCreated: (newValue) => {
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
piece.value.customFieldValues.push(newValue)
@@ -435,6 +436,7 @@ export function usePieceEdit(pieceId: string) {
constructeurIdsFromForm,
productSelections,
customFieldInputs,
requiredCustomFieldsFilled,
canEdit,
// Computed

View File

@@ -218,6 +218,9 @@
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div>
<EmptyState
v-else
@@ -237,6 +240,9 @@
Créer la pièce
</button>
</div>
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires avant de créer la pièce.
</p>
</div>
</section>
</main>
@@ -311,7 +317,9 @@ const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, s
values: [] as any[],
entityType: 'piece' as CustomFieldEntityType,
entityId: createdEntityId,
context: 'standalone',
})
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)

View File

@@ -274,6 +274,9 @@
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -338,7 +341,7 @@
Enregistrer les modifications
</button>
</div>
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
@@ -409,6 +412,7 @@ const {
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
context: 'standalone',
})
const loading = ref(true)
const saving = ref(false)
@@ -446,7 +450,7 @@ const editionForm = reactive({
supplierPrice: '' as string,
})
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),

View File

@@ -158,6 +158,9 @@
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div>
<EmptyState
v-else
@@ -177,7 +180,7 @@
Créer le produit
</button>
</div>
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
@@ -241,7 +244,9 @@ const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, s
values: [] as any[],
entityType: 'product' as CustomFieldEntityType,
entityId: createdEntityId,
context: 'standalone',
})
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],

View File

@@ -0,0 +1,737 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
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(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComposants (createComposant)
// ---------------------------------------------------------------------------
const mockCreateComposant = vi.fn()
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
createComposant: mockCreateComposant,
composants: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces, useProducts
// ---------------------------------------------------------------------------
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
pieces: { value: [] },
loading: { value: false },
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
// ---------------------------------------------------------------------------
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
const mockComponentTypes = { value: [] as any[] }
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: mockComponentTypes,
loadComponentTypes: mockLoadComponentTypes,
loadingComponentTypes: { value: false },
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: { value: [] },
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: { value: [] },
loadProductTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments (uploadDocuments)
// ---------------------------------------------------------------------------
const mockUploadDocuments = vi.fn()
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
uploadDocuments: mockUploadDocuments,
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks (syncLinks)
// ---------------------------------------------------------------------------
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: vi.fn().mockResolvedValue([]),
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs (saveAll)
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
// usePermissions is Nuxt auto-imported (no explicit import in source),
// so we stub it as a global function.
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs (used by useComposants internally)
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils that touch structure
// ---------------------------------------------------------------------------
const mockHasAssignments = vi.fn().mockReturnValue(false)
const mockSerializeStructureAssignments = vi.fn().mockReturnValue(null)
const mockIsAssignmentNodeComplete = vi.fn().mockReturnValue(true)
vi.mock('~/shared/utils/structureAssignmentHelpers', () => ({
hasAssignments: (...args: any[]) => mockHasAssignments(...args),
initializeStructureAssignments: () => null,
isAssignmentNodeComplete: (...args: any[]) => mockIsAssignmentNodeComplete(...args),
serializeStructureAssignments: (...args: any[]) => mockSerializeStructureAssignments(...args),
}))
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
getStructurePieces: () => [],
resolvePieceLabel: (p: any) => p?.name ?? '',
resolveProductLabel: (p: any) => p?.name ?? '',
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
buildTypeLabelMap: () => ({}),
}))
vi.mock('~/shared/modelUtils', () => ({
formatStructurePreview: () => '',
normalizeStructureForEditor: (s: any) => s,
}))
vi.mock('~/shared/utils/errorMessages', () => ({
humanizeError: (msg: string) => msg,
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** A minimal ModelType matching the `COMPONENT` category filter. */
const mockModelType = {
id: 'tc-moteur',
name: 'Moteur électrique',
category: 'COMPONENT',
structure: null,
}
beforeEach(() => {
vi.clearAllMocks()
// Provide at least one COMPONENT type so selectedType resolves
mockComponentTypes.value = [mockModelType]
})
// ---------------------------------------------------------------------------
// submitCreation — payload completeness
// ---------------------------------------------------------------------------
describe('submitCreation — payload completeness', () => {
it('includes all form fields in createComposant payload', async () => {
const createdComp = { id: 'comp-new-001', name: 'Moteur principal' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
// Select a type
composable.selectedTypeId.value = 'tc-moteur'
// Wait a tick so watchers fire
await new Promise(r => setTimeout(r, 0))
// Fill form fields
composable.creationForm.name = 'Moteur principal'
composable.creationForm.description = 'Un moteur triphasé'
composable.creationForm.reference = 'MOT-001'
composable.creationForm.prix = '1500'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).toMatchObject({
name: 'Moteur principal',
description: 'Un moteur triphasé',
reference: 'MOT-001',
prix: '1500',
typeComposantId: 'tc-moteur',
})
})
it('saves custom fields after component creation (saveAll is called)', async () => {
const createdComp = { id: 'comp-cf-001', name: 'Composant CF' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant CF'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('syncs constructeur links after creation with correct entity type and ID', async () => {
const createdComp = { id: 'comp-link-001', name: 'Composant Links' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Links'
// Add constructeur links
composable.constructeurLinks.value = [mockLinkSKF, mockLinkFAG]
await composable.submitCreation()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
expect(mockSyncLinks).toHaveBeenCalledWith(
'composant',
'comp-link-001',
[],
[mockLinkSKF, mockLinkFAG],
)
})
it('uploads documents with correct composantId context', async () => {
const createdComp = { id: 'comp-doc-001', name: 'Composant Docs' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Docs'
// Simulate selected documents
const fakeFile = new File(['content'], 'schema.pdf', { type: 'application/pdf' })
composable.selectedDocuments.value = [fakeFile]
await composable.submitCreation()
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
expect(mockUploadDocuments).toHaveBeenCalledWith(
{
files: [fakeFile],
context: { composantId: 'comp-doc-001' },
},
{ updateStore: false },
)
})
it('does not crash with zero constructeurs', async () => {
const createdComp = { id: 'comp-no-cstr', name: 'Composant Simple' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Simple'
// Ensure no constructeur links
composable.constructeurLinks.value = []
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSyncLinks).not.toHaveBeenCalled()
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
})
})
// ---------------------------------------------------------------------------
// Structure serialization in payload
// ---------------------------------------------------------------------------
describe('submitCreation — structure serialization in payload', () => {
it('includes structure key with serialized data when assignments exist', async () => {
const createdComp = { id: 'comp-struct-001', name: 'Composant Structure' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const fakeSerializedStructure = {
path: 'root',
definition: { typeComposantId: 'tc-moteur' },
pieces: [{ path: 'root:piece-0', definition: { typePieceId: 'tp-001' }, selectedPieceId: 'piece-abc' }],
}
mockHasAssignments.mockReturnValue(true)
mockSerializeStructureAssignments.mockReturnValue(fakeSerializedStructure)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Structure'
// Set a non-null structureAssignments so the composable considers it present
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [{ path: 'root:piece-0', definition: {} as any, selectedPieceId: 'piece-abc' }],
products: [],
subcomponents: [],
}
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toEqual(fakeSerializedStructure)
})
it('does not include structure key when no assignments exist', async () => {
const createdComp = { id: 'comp-nostruct-001', name: 'Composant No Structure' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
// Reset to default: no assignments
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Structure'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toBeUndefined()
})
it('does not include structure key when serializeStructureAssignments returns null', async () => {
const createdComp = { id: 'comp-sernull-001', name: 'Composant Serialize Null' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(true)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Serialize Null'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [],
subcomponents: [],
}
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// Prix / reference null handling
// ---------------------------------------------------------------------------
describe('submitCreation — prix and reference null handling', () => {
it('does not send prix when prix is an empty string', async () => {
const createdComp = { id: 'comp-noprix-001', name: 'Composant No Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
// Reset structure mocks to default
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Prix'
composable.creationForm.prix = ''
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('prix')
})
it('does not send prix when prix is non-numeric (avoids NaN)', async () => {
const createdComp = { id: 'comp-nanprix-001', name: 'Composant NaN Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant NaN Prix'
composable.creationForm.prix = 'not-a-number'
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('prix')
})
it('sends prix as stringified number when valid numeric string', async () => {
const createdComp = { id: 'comp-validprix-001', name: 'Composant Valid Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Valid Prix'
composable.creationForm.prix = ' 42.5 '
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.prix).toBe('42.5')
})
it('does not send reference when reference is an empty string', async () => {
const createdComp = { id: 'comp-noref-001', name: 'Composant No Ref' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Ref'
composable.creationForm.reference = ''
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('reference')
})
it('does not send reference when reference is whitespace only', async () => {
const createdComp = { id: 'comp-wsref-001', name: 'Composant WS Ref' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant WS Ref'
composable.creationForm.reference = ' '
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('reference')
})
})
// ---------------------------------------------------------------------------
// Error paths
// ---------------------------------------------------------------------------
describe('submitCreation — error paths', () => {
beforeEach(() => {
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
})
it('does not save custom fields when createComposant returns success: false', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'Duplicate name' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Fail'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Duplicate name')
})
it('shows toast error when createComposant returns success: false with error message', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'Server validation failed' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Error'
await composable.submitCreation()
expect(mockShowError).toHaveBeenCalledWith('Server validation failed')
expect(mockShowSuccess).not.toHaveBeenCalled()
})
it('shows warning for failed custom fields but still navigates (composant exists)', async () => {
const createdComp = { id: 'comp-cf-warn-001', name: 'Composant CF Warn' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockSaveAll.mockResolvedValue(['Tension nominale', 'Certifié CE'])
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant CF Warn'
await composable.submitCreation()
// Custom field error toast is shown
expect(mockShowError).toHaveBeenCalledWith(
'Erreur sur les champs : Tension nominale, Certifié CE',
)
// But creation success toast is also shown (composant was created)
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
})
it('catches thrown exceptions and shows humanized error', async () => {
mockCreateComposant.mockRejectedValue(new Error('Network timeout'))
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Throw'
await composable.submitCreation()
expect(mockShowError).toHaveBeenCalledWith('Network timeout')
expect(mockSaveAll).not.toHaveBeenCalled()
})
it('resets submitting flag after failure', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'fail' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Reset Flag'
await composable.submitCreation()
expect(composable.submitting.value).toBe(false)
})
})
// ---------------------------------------------------------------------------
// ProductId from structure
// ---------------------------------------------------------------------------
describe('submitCreation — productId from structure', () => {
beforeEach(() => {
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
})
it('includes productId in payload when root product selection exists', async () => {
const createdComp = { id: 'comp-prodid-001', name: 'Composant ProductId' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant ProductId'
// Set structure assignments with a root product selection
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [
{
path: 'root:product-0',
definition: { typeProductId: 'tprod-001' } as any,
selectedProductId: 'prod-selected-123',
},
],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.productId).toBe('prod-selected-123')
})
it('does not include productId when no root product is selected', async () => {
const createdComp = { id: 'comp-noprodid-001', name: 'Composant No ProductId' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No ProductId'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('productId')
})
it('does not include productId when product selection is empty string', async () => {
const createdComp = { id: 'comp-emptyprod-001', name: 'Composant Empty Product' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Empty Product'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [
{
path: 'root:product-0',
definition: { typeProductId: 'tprod-001' } as any,
selectedProductId: '',
},
],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('productId')
})
})

View File

@@ -0,0 +1,890 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
mockComponentFromApi,
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
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(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComposants (updateComposant)
// ---------------------------------------------------------------------------
const mockUpdateComposant = vi.fn()
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
updateComposant: mockUpdateComposant,
composants: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces, useProducts
// ---------------------------------------------------------------------------
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
pieces: { value: [] },
loading: { value: false },
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
// ---------------------------------------------------------------------------
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
const mockComponentTypes = { value: [] as any[] }
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: mockComponentTypes,
loadComponentTypes: mockLoadComponentTypes,
loadingComponentTypes: { value: false },
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: { value: [] },
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: { value: [] },
loadProductTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments
// ---------------------------------------------------------------------------
const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] })
const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] })
const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true })
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByComponent: mockLoadDocumentsByComponent,
uploadDocuments: mockUploadDocuments,
deleteDocument: mockDeleteDocument,
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks
// ---------------------------------------------------------------------------
const mockFetchLinks = vi.fn().mockResolvedValue([])
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: mockFetchLinks,
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useEntityHistory
// ---------------------------------------------------------------------------
vi.mock('~/composables/useEntityHistory', () => ({
useEntityHistory: () => ({
history: { value: [] },
loading: { value: false },
error: { value: null },
loadHistory: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils
// ---------------------------------------------------------------------------
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
getStructurePieces: (s: any) => Array.isArray(s?.pieces) ? s.pieces : [],
getStructureProducts: (s: any) => Array.isArray(s?.products) ? s.products : [],
resolvePieceLabel: (p: any) => p?.name ?? '',
resolveProductLabel: (p: any) => p?.name ?? '',
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
buildTypeLabelMap: () => ({}),
}))
vi.mock('~/shared/modelUtils', () => ({
formatStructurePreview: () => '',
normalizeStructureForEditor: (s: any) => s,
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
vi.mock('~/shared/utils/structureSelectionUtils', () => ({
collectStructureSelections: () => ({ pieces: [], products: [], components: [] }),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Test data — component with structure containing slots
// ---------------------------------------------------------------------------
const COMPONENT_ID = 'cl-comp-1'
function buildComponentWithStructure() {
return {
...mockComponentFromApi,
id: COMPONENT_ID,
'@id': `/api/composants/${COMPONENT_ID}`,
description: 'Un moteur triphas\u00e9 haute performance',
prix: '1500.00',
typeComposantId: 'tc-moteur',
structure: {
pieces: [
{
slotId: 'ps-001',
typePieceId: 'tp-bearing-001',
selectedPieceId: 'piece-001',
selectedPieceName: 'Roulement 6205',
quantity: 2,
position: 0,
},
{
slotId: 'ps-002',
typePieceId: 'tp-seal-002',
selectedPieceId: 'piece-002',
selectedPieceName: 'Joint torique',
quantity: 1,
position: 1,
},
],
products: [
{
slotId: 'prs-001',
typeProductId: 'tprod-grease-001',
selectedProductId: 'prod-001',
selectedProductName: 'Graisse LGMT2',
familyCode: 'LUB',
position: 0,
},
],
subcomponents: [
{
slotId: 'scs-001',
typeComposantId: 'tc-sub-001',
selectedComponentId: 'comp-sub-001',
selectedComponentName: 'Palier avant',
alias: 'Palier avant',
familyCode: 'PAL',
position: 0,
},
],
customFields: [],
},
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Wait for next tick + micro-tasks so watchers fire. */
const tick = () => new Promise(r => setTimeout(r, 0))
/**
* Create the composable AND hydrate it by resolving the mocked get.
* Returns the composable instance after fetch + watcher hydration.
*/
async function createAndHydrate(overrides?: Partial<ReturnType<typeof buildComponentWithStructure>>) {
const comp = { ...buildComponentWithStructure(), ...overrides }
mockGet.mockImplementation((url: string) => {
if (url.includes(`/composants/${COMPONENT_ID}`)) {
return Promise.resolve({ success: true, data: structuredClone(comp) })
}
return Promise.resolve({ success: true, data: wrapCollection([]) })
})
mockFetchLinks.mockResolvedValue([
{ ...mockLinkSKF },
])
const composable = useComponentEdit(COMPONENT_ID)
// fetchComponent is called, then the watcher hydrates editionForm
await composable.fetchComponent()
await tick()
return composable
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockComponentTypes.value = [
{ id: 'tc-moteur', name: 'Moteur \u00e9lectrique', category: 'COMPONENT', structure: null },
]
})
// ---------------------------------------------------------------------------
// fetchComponent — hydration
// ---------------------------------------------------------------------------
describe('fetchComponent — hydration', () => {
it('loads simple fields into editionForm (name, reference, description, prix)', async () => {
const composable = await createAndHydrate()
expect(composable.editionForm.name).toBe('Moteur principal')
expect(composable.editionForm.reference).toBe('COMP-MOT-001')
expect(composable.editionForm.description).toBe('Un moteur triphas\u00e9 haute performance')
expect(composable.editionForm.prix).toBe('1500.00')
})
it('loads component object with structure containing slots', async () => {
const composable = await createAndHydrate()
expect(composable.component.value).not.toBeNull()
expect(composable.component.value.structure).toBeDefined()
expect(composable.component.value.structure.pieces).toHaveLength(2)
expect(composable.component.value.structure.products).toHaveLength(1)
expect(composable.component.value.structure.subcomponents).toHaveLength(1)
expect(composable.component.value.customFieldValues).toBeDefined()
expect(Array.isArray(composable.component.value.customFieldValues)).toBe(true)
})
it('loads constructeur links via fetchLinks', async () => {
const composable = await createAndHydrate()
expect(mockFetchLinks).toHaveBeenCalledWith('composant', COMPONENT_ID)
expect(composable.constructeurLinks.value).toHaveLength(1)
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
})
})
// ---------------------------------------------------------------------------
// Slot operations — no data loss
// ---------------------------------------------------------------------------
describe('slot operations — no data loss', () => {
it('setting piece slot selection preserves product and subcomponent slots', async () => {
const composable = await createAndHydrate()
// Record initial product and subcomponent slot entries
const initialProductSlots = composable.productSlotEntries.value
const initialSubSlots = composable.subcomponentSlotEntries.value
expect(initialProductSlots).toHaveLength(1)
expect(initialSubSlots).toHaveLength(1)
// Change a piece slot selection
composable.setPieceSlotSelection('ps-001', 'piece-999')
await tick()
// Piece slot changed
const pieceSlots = composable.pieceSlotEntries.value
expect(pieceSlots.find(s => s.slotId === 'ps-001')?.selectedPieceId).toBe('piece-999')
// Product and subcomponent slots untouched
expect(composable.productSlotEntries.value).toHaveLength(1)
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
expect(composable.subcomponentSlotEntries.value).toHaveLength(1)
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-sub-001')
})
it('setting product slot selection preserves piece slots', async () => {
const composable = await createAndHydrate()
// Change a product slot
composable.setProductSlotSelection('prs-001', 'prod-new-001')
await tick()
// Product changed
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-new-001')
// Piece slots untouched
expect(composable.pieceSlotEntries.value).toHaveLength(2)
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
expect(composable.pieceSlotEntries.value[1].selectedPieceId).toBe('piece-002')
})
it('setting subcomponent slot selection preserves piece and product slots', async () => {
const composable = await createAndHydrate()
// Change a subcomponent slot
composable.setSubcomponentSlotSelection('scs-001', 'comp-new-sub')
await tick()
// Subcomponent changed
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-new-sub')
// Piece and product slots untouched
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
})
it('setting slot quantity preserves selectedPieceId', async () => {
const composable = await createAndHydrate()
// Set a piece selection first
composable.setPieceSlotSelection('ps-001', 'piece-special')
await tick()
// Now change quantity on the same slot
composable.setSlotQuantity('ps-001', 5)
await tick()
const slot = composable.pieceSlotEntries.value.find(s => s.slotId === 'ps-001')
expect(slot?.selectedPieceId).toBe('piece-special')
expect(slot?.quantity).toBe(5)
})
})
// ---------------------------------------------------------------------------
// submitEdition — no data loss
// ---------------------------------------------------------------------------
describe('submitEdition — no data loss', () => {
it('sends all form fields in PATCH payload via updateComposant', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Modify form fields
composable.editionForm.name = 'Moteur modifi\u00e9'
composable.editionForm.description = 'Nouvelle description'
composable.editionForm.reference = 'REF-MOD-001'
composable.editionForm.prix = '2500'
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload).toMatchObject({
name: 'Moteur modifi\u00e9',
description: 'Nouvelle description',
reference: 'REF-MOD-001',
prix: '2500',
})
})
it('saves custom fields after patch', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('patches slot edits to correct endpoints', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockPatch.mockResolvedValue({ success: true, data: {} })
const composable = await createAndHydrate()
// Make slot edits
composable.setPieceSlotSelection('ps-001', 'piece-new')
composable.setSlotQuantity('ps-002', 3)
composable.setProductSlotSelection('prs-001', 'prod-new')
composable.setSubcomponentSlotSelection('scs-001', 'comp-new')
await composable.submitEdition()
// Verify piece slot patches
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-new' })
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-002', { quantity: 3 })
// Verify product slot patch
expect(mockPatch).toHaveBeenCalledWith('/composant-product-slots/prs-001', { selectedProductId: 'prod-new' })
// Verify subcomponent slot patch
expect(mockPatch).toHaveBeenCalledWith('/composant-subcomponent-slots/scs-001', { selectedComposantId: 'comp-new' })
})
it('syncs constructeur links with diff', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Add a second constructeur link
composable.constructeurLinks.value = [
{ ...mockLinkSKF },
{ ...mockLinkFAG },
]
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
// originalConstructeurLinks was set to [mockLinkSKF] from fetchLinks
// formLinks is now [mockLinkSKF, mockLinkFAG]
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(entityType).toBe('composant')
expect(entityId).toBe(COMPONENT_ID)
expect(origLinks).toHaveLength(1)
expect(formLinks).toHaveLength(2)
})
it('editing name does not lose constructeur links', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Only edit name
composable.editionForm.name = 'Nouveau nom moteur'
await composable.submitEdition()
// updateComposant was called with name change
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.name).toBe('Nouveau nom moteur')
// syncLinks was still called (preserving links)
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
// Both should contain the original SKF link
expect(origLinks).toHaveLength(1)
expect(formLinks).toHaveLength(1)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
})
})
// ---------------------------------------------------------------------------
// Document operations
// ---------------------------------------------------------------------------
describe('document operations', () => {
it('populates componentDocuments from fetchComponent response', async () => {
const docFixtures = [
{ id: 'doc-1', name: 'photo.jpg', type: 'photo' },
{ id: 'doc-2', name: 'schema.pdf', type: 'schema' },
]
const composable = await createAndHydrate({ documents: docFixtures } as any)
expect(composable.componentDocuments.value).toHaveLength(2)
expect(composable.componentDocuments.value[0].id).toBe('doc-1')
expect(composable.componentDocuments.value[1].id).toBe('doc-2')
})
it('sets componentDocuments to empty array when response has no documents', async () => {
const composable = await createAndHydrate({ documents: undefined } as any)
expect(composable.componentDocuments.value).toEqual([])
})
it('handleFilesAdded calls uploadDocuments with composantId context', async () => {
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] })
const composable = await createAndHydrate()
const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })]
await composable.handleFilesAdded(files)
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
const callArgs = mockUploadDocuments.mock.calls[0]![0]
expect(callArgs.files).toBe(files)
expect(callArgs.context.composantId).toBe(COMPONENT_ID)
})
it('handleFilesAdded does nothing when files array is empty', async () => {
const composable = await createAndHydrate()
await composable.handleFilesAdded([])
expect(mockUploadDocuments).not.toHaveBeenCalled()
})
it('handleFilesAdded refreshes documents after successful upload', async () => {
const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }]
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs })
const composable = await createAndHydrate()
const files = [new File(['data'], 'uploaded.pdf')]
await composable.handleFilesAdded(files)
expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false })
expect(composable.componentDocuments.value).toHaveLength(1)
expect(composable.componentDocuments.value[0].id).toBe('doc-new')
})
it('removeDocument calls deleteDocument and removes from local list', async () => {
mockDeleteDocument.mockResolvedValue({ success: true })
const composable = await createAndHydrate({
documents: [
{ id: 'doc-a', name: 'a.pdf' },
{ id: 'doc-b', name: 'b.pdf' },
],
} as any)
expect(composable.componentDocuments.value).toHaveLength(2)
await composable.removeDocument('doc-a')
expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false })
expect(composable.componentDocuments.value).toHaveLength(1)
expect(composable.componentDocuments.value[0].id).toBe('doc-b')
})
it('removeDocument does nothing when documentId is falsy', async () => {
const composable = await createAndHydrate()
await composable.removeDocument(null)
await composable.removeDocument(undefined)
expect(mockDeleteDocument).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// Null field handling in PATCH payload
// ---------------------------------------------------------------------------
describe('null field handling in PATCH payload', () => {
it('empty prix string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('whitespace-only prix string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ' '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('valid prix string sends stringified number', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = '42.50'
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBe('42.5')
})
it('empty reference string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.reference = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.reference).toBeNull()
})
it('empty description string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.description = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.description).toBeNull()
})
it('whitespace-only description sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.description = ' '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.description).toBeNull()
})
it('name is trimmed but never null', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.name = ' Moteur '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.name).toBe('Moteur')
})
})
// ---------------------------------------------------------------------------
// Error paths
// ---------------------------------------------------------------------------
describe('error paths', () => {
it('does not save custom fields when updateComposant returns { success: false }', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
composable.editionForm.name = 'Test'
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('does not patch slots when updateComposant returns { success: false }', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
composable.setPieceSlotSelection('ps-001', 'piece-new')
composable.setProductSlotSelection('prs-001', 'prod-new')
await composable.submitEdition()
expect(mockPatch).not.toHaveBeenCalled()
})
it('does not sync constructeur links when updateComposant fails', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('shows error toast when saveAllCustomFields returns failed fields', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection'])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale')
expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection')
})
it('still saves slots and syncs links even when custom fields fail', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Tension nominale'])
mockPatch.mockResolvedValue({ success: true, data: {} })
const composable = await createAndHydrate()
composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail')
await composable.submitEdition()
// Slots still patched despite custom field failure
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' })
// Links still synced
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
// Success toast still shown (alongside the error toast for CF)
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
})
it('shows error toast when submitEdition throws', async () => {
mockUpdateComposant.mockRejectedValue(new Error('Network failure'))
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
expect(mockShowError.mock.calls[0]![0]).toContain('Network failure')
expect(composable.saving.value).toBe(false)
})
it('resets saving flag even when updateComposant throws', async () => {
mockUpdateComposant.mockRejectedValue(new Error('Server error'))
const composable = await createAndHydrate()
await composable.submitEdition()
expect(composable.saving.value).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Custom field save verification
// ---------------------------------------------------------------------------
describe('custom field save verification', () => {
it('saveAllCustomFields is called after successful updateComposant', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue([])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('does not show error toast when saveAll returns empty array (no failures)', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue([])
const composable = await createAndHydrate()
await composable.submitEdition()
// showError should NOT have been called (only showSuccess)
expect(mockShowError).not.toHaveBeenCalled()
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
})
it('shows error with all failed field names joined', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C'])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
const errorMsg = mockShowError.mock.calls[0]![0] as string
expect(errorMsg).toContain('Champ A')
expect(errorMsg).toContain('Champ B')
expect(errorMsg).toContain('Champ C')
})
it('submitEdition does nothing when component is null', async () => {
const composable = await createAndHydrate()
// Force component to null
composable.component.value = null
await composable.submitEdition()
expect(mockUpdateComposant).not.toHaveBeenCalled()
expect(mockSaveAll).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useComposants } from '~/composables/useComposants'
import { mockComponentFromApi, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearComposantsCache } = useComposants()
clearComposantsCache()
})
// ---------------------------------------------------------------------------
// createComposant
// ---------------------------------------------------------------------------
describe('createComposant', () => {
it('sends all fields in creation payload', async () => {
const created = { ...mockComponentFromApi, id: 'comp-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createComposant } = useComposants()
const result = await createComposant({
name: 'Moteur principal',
reference: 'COMP-MOT-001',
description: 'Un moteur',
typeComposantId: 'tc-moteur',
})
expect(result.success).toBe(true)
// normalizeRelationIds converts typeComposantId to typeComposant IRI
expect(mockPost).toHaveBeenCalledWith('/composants', expect.objectContaining({
name: 'Moteur principal',
reference: 'COMP-MOT-001',
description: 'Un moteur',
typeComposant: '/api/model_types/tc-moteur',
}))
// typeComposantId should be removed by normalizeRelationIds
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('typeComposantId')
})
it('adds created composant to cache', async () => {
const created = { ...mockComponentFromApi, id: 'comp-new', name: 'Nouveau composant' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createComposant, composants, total } = useComposants()
expect(composants.value).toHaveLength(0)
expect(total.value).toBe(0)
await createComposant({ name: 'Nouveau composant' })
expect(composants.value).toHaveLength(1)
expect(composants.value[0]!.id).toBe('comp-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updateComposant
// ---------------------------------------------------------------------------
describe('updateComposant', () => {
it('patches and updates cache', async () => {
// Seed the cache with one composant
const original = { ...mockComponentFromApi, id: 'comp-001', name: 'Ancien nom' }
mockPost.mockResolvedValue({ success: true, data: original })
const { createComposant, updateComposant, composants } = useComposants()
await createComposant({ name: 'Ancien nom' })
expect(composants.value).toHaveLength(1)
// Now update
const updated = { ...original, name: 'Nouveau nom' }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updateComposant('comp-001', { name: 'Nouveau nom' })
expect(result.success).toBe(true)
expect(mockPatch).toHaveBeenCalledWith('/composants/comp-001', expect.objectContaining({
name: 'Nouveau nom',
}))
expect(composants.value[0]!.name).toBe('Nouveau nom')
})
})
// ---------------------------------------------------------------------------
// deleteComposant
// ---------------------------------------------------------------------------
describe('deleteComposant', () => {
it('removes composant from cache on success', async () => {
// Seed cache
const item = { ...mockComponentFromApi, id: 'comp-del', name: 'A supprimer' }
mockPost.mockResolvedValue({ success: true, data: item })
const { createComposant, deleteComposant, composants, total } = useComposants()
await createComposant({ name: 'A supprimer' })
expect(composants.value).toHaveLength(1)
expect(total.value).toBe(1)
mockDel.mockResolvedValue({ success: true })
const result = await deleteComposant('comp-del')
expect(result.success).toBe(true)
expect(composants.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('keeps composant in cache on failure', async () => {
// Seed cache
const item = { ...mockComponentFromApi, id: 'comp-keep', name: 'Garder' }
mockPost.mockResolvedValue({ success: true, data: item })
const { createComposant, deleteComposant, composants, total } = useComposants()
await createComposant({ name: 'Garder' })
expect(composants.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deleteComposant('comp-keep')
expect(result.success).toBe(false)
expect(composants.value).toHaveLength(1)
expect(composants.value[0]!.id).toBe('comp-keep')
})
})

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import {
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
mockConstructeurFAG,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// fetchLinks
// ---------------------------------------------------------------------------
describe('fetchLinks', () => {
it('returns parsed links with all properties for composant', async () => {
const apiLinks = [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
{
id: mockLinkFAG.linkId,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
},
]
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
linkId: mockLinkSKF.linkId,
constructeurId: mockConstructeurSKF.id,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
})
expect(result[1]).toEqual({
linkId: mockLinkFAG.linkId,
constructeurId: mockConstructeurFAG.id,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
})
})
it('returns supplierReference as null when absent from API', async () => {
const apiLinks = [
{
id: 'link-no-ref',
constructeur: mockConstructeurSKF,
// no supplierReference key
},
]
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toHaveLength(1)
expect(result[0]!.supplierReference).toBeNull()
})
it.each([
['machine', '/machine_constructeur_links?machine=/api/machines/m-001', 'm-001'],
['product', '/product_constructeur_links?product=/api/products/p-001', 'p-001'],
['piece', '/piece_constructeur_links?piece=/api/pieces/pc-001', 'pc-001'],
['composant', '/composant_constructeur_links?composant=/api/composants/c-001', 'c-001'],
] as const)('uses correct endpoint for %s', async (entityType, expectedUrl, entityId) => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([]) })
const { fetchLinks } = useConstructeurLinks()
await fetchLinks(entityType, entityId)
expect(mockGet).toHaveBeenCalledWith(expectedUrl)
})
it('returns empty array on API failure', async () => {
mockGet.mockResolvedValue({ success: false, data: null })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toEqual([])
})
})
// ---------------------------------------------------------------------------
// syncLinks — 3-way diff
// ---------------------------------------------------------------------------
describe('syncLinks', () => {
it('creates new links via POST', async () => {
mockPost.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [mockLinkSKF])
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurSKF.id}`,
supplierReference: mockLinkSKF.supplierReference,
})
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('deletes removed links via DELETE', async () => {
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [])
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('patches when supplierReference changes (value to new value)', async () => {
mockPatch.mockResolvedValue({ success: true })
const updatedLink = { ...mockLinkSKF, supplierReference: 'NEW-REF-999' }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
supplierReference: 'NEW-REF-999',
})
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
})
it('patches when supplierReference changes from value to null', async () => {
mockPatch.mockResolvedValue({ success: true })
const updatedLink = { ...mockLinkSKF, supplierReference: null }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
supplierReference: null,
})
})
it('does nothing when links are identical (no API calls)', async () => {
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkSKF])
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('handles add + delete in same operation', async () => {
mockPost.mockResolvedValue({ success: true })
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkFAG])
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: mockLinkFAG.supplierReference,
})
expect(mockPatch).not.toHaveBeenCalled()
})
it('handles empty original and empty form (no-op)', async () => {
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [])
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('sends supplierReference as null when empty string', async () => {
mockPost.mockResolvedValue({ success: true })
const linkWithEmpty = { ...mockLinkFAG, supplierReference: '' }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [linkWithEmpty])
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: null,
})
})
it.each([
['machine', '/machine_constructeur_links', 'machine', '/api/machines/m-001', 'm-001'],
['product', '/product_constructeur_links', 'product', '/api/products/p-001', 'p-001'],
['piece', '/piece_constructeur_links', 'piece', '/api/pieces/pc-001', 'pc-001'],
['composant', '/composant_constructeur_links', 'composant', '/api/composants/c-001', 'c-001'],
] as const)('uses correct endpoint and entity IRI for %s', async (entityType, endpoint, key, entityIri, entityId) => {
mockPost.mockResolvedValue({ success: true })
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks(entityType, entityId, [mockLinkSKF], [mockLinkFAG])
expect(mockDel).toHaveBeenCalledWith(`${endpoint}/${mockLinkSKF.linkId}`)
expect(mockPost).toHaveBeenCalledWith(endpoint, {
[key]: entityIri,
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: mockLinkFAG.supplierReference,
})
})
})

View File

@@ -0,0 +1,475 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import {
shouldPersist,
formatValueForSave,
} from '~/shared/utils/customFields'
import {
mockCustomFieldDefs,
mockCustomFieldValues,
mockMachineCustomFieldDefs,
mockMachineCustomFieldValues,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockUpdateCustomFieldValue = vi.fn()
const mockUpsertCustomFieldValue = vi.fn()
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
updateCustomFieldValue: mockUpdateCustomFieldValue,
upsertCustomFieldValue: mockUpsertCustomFieldValue,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } })
})
// ---------------------------------------------------------------------------
// Field initialization
// ---------------------------------------------------------------------------
describe('field initialization', () => {
it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => {
const { fields, allFields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(allFields.value).toHaveLength(6)
expect(fields.value).toHaveLength(5)
})
it('preserves value for number type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const numberField = fields.value.find(f => f.name === 'Tension nominale')
expect(numberField?.value).toBe('220')
expect(numberField?.type).toBe('number')
})
it('preserves value for boolean type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const boolField = fields.value.find(f => f.name === 'Certifié CE')
expect(boolField?.value).toBe('true')
expect(boolField?.type).toBe('boolean')
})
it('preserves value for select type with options', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const selectField = fields.value.find(f => f.name === 'Indice de protection')
expect(selectField?.value).toBe('IP65')
expect(selectField?.type).toBe('select')
expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65'])
})
it('preserves value for date type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const dateField = fields.value.find(f => f.name === 'Date de calibration')
expect(dateField?.value).toBe('2025-06-15')
expect(dateField?.type).toBe('date')
})
it('preserves value for text type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const textField = fields.value.find(f => f.name === 'Remarques techniques')
expect(textField?.value).toBe('Roulement renforcé pour environnement humide')
expect(textField?.type).toBe('text')
})
it('uses defaultValue when no persisted value exists', () => {
// Pass empty values array so all fields use defaultValue
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const numberField = fields.value.find(f => f.name === 'Tension nominale')
expect(numberField?.value).toBe('220')
const boolField = fields.value.find(f => f.name === 'Certifié CE')
expect(boolField?.value).toBe('false')
// No defaultValue → empty string
const dateField = fields.value.find(f => f.name === 'Date de calibration')
expect(dateField?.value).toBe('')
})
it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => {
const { fields, allFields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(allFields.value).toHaveLength(6)
expect(fields.value).toHaveLength(5)
expect(fields.value.every(f => !f.machineContextOnly)).toBe(true)
})
it('shows only machineContextOnly in machine context (1 field)', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'machine',
})
expect(fields.value).toHaveLength(1)
expect(fields.value[0]?.name).toBe('Position sur machine')
expect(fields.value[0]?.machineContextOnly).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Boolean — the tricky case
// ---------------------------------------------------------------------------
describe('boolean — the tricky case', () => {
it('saves "false" value via update (not ignored)', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const boolField = fields.value.find(f => f.name === 'Certifié CE')!
boolField.value = 'false'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
})
it('persists boolean "false" in saveAll (not skipped)', async () => {
// Only provide the boolean field def + value
const boolDef = mockCustomFieldDefs[1]!
const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' }
const { fields, saveAll } = useCustomFieldInputs({
definitions: ref([boolDef]),
values: ref([boolVal]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(fields.value[0]?.value).toBe('false')
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
})
})
// ---------------------------------------------------------------------------
// Number zero
// ---------------------------------------------------------------------------
describe('number zero', () => {
it('saves "0" value (not ignored)', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockMachineCustomFieldDefs),
values: ref(mockMachineCustomFieldValues),
entityType: 'machine',
entityId: ref('cl-machine-1'),
context: 'standalone',
})
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
expect(numField.value).toBe('0')
await update(numField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
})
})
// ---------------------------------------------------------------------------
// Text empty string
// ---------------------------------------------------------------------------
describe('text empty string', () => {
it('shouldPersist returns false for empty trimmed string', () => {
const field = {
customFieldId: 'cf-1',
customFieldValueId: null,
name: 'Notes',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
value: ' ',
}
expect(shouldPersist(field)).toBe(false)
})
it('persists non-empty text value', () => {
const field = {
customFieldId: 'cf-1',
customFieldValueId: null,
name: 'Notes',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
value: 'some text',
}
expect(shouldPersist(field)).toBe(true)
expect(formatValueForSave(field)).toBe('some text')
})
})
// ---------------------------------------------------------------------------
// Select
// ---------------------------------------------------------------------------
describe('select', () => {
it('saves changed option value', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const selectField = fields.value.find(f => f.name === 'Indice de protection')!
selectField.value = 'IP55'
await update(selectField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' })
})
})
// ---------------------------------------------------------------------------
// Date
// ---------------------------------------------------------------------------
describe('date', () => {
it('saves date value in correct format', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const dateField = fields.value.find(f => f.name === 'Date de calibration')!
dateField.value = '2026-01-20'
await update(dateField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' })
})
})
// ---------------------------------------------------------------------------
// saveAll isolation
// ---------------------------------------------------------------------------
describe('saveAll isolation', () => {
it('saves all fields independently without losing values', async () => {
const { fields, saveAll } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
// Modify one field
const numberField = fields.value.find(f => f.name === 'Tension nominale')!
numberField.value = '380'
const failed = await saveAll()
expect(failed).toEqual([])
// All persistable fields should have been saved
// 5 fields in standalone context, all have values
expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4)
// The modified field should have the new value
const numberCall = mockUpdateCustomFieldValue.mock.calls.find(
(c: any[]) => c[0] === 'cfv-001',
)
expect(numberCall?.[1]).toEqual({ value: '380' })
// Another field should still have its original value
const boolCall = mockUpdateCustomFieldValue.mock.calls.find(
(c: any[]) => c[0] === 'cfv-002',
)
expect(boolCall?.[1]).toEqual({ value: 'true' })
})
it('upserts new value when no customFieldValueId exists', async () => {
// Use defs without matching values — no customFieldValueId
const defs = [mockCustomFieldDefs[0]!]
const { saveAll } = useCustomFieldInputs({
definitions: ref(defs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const failed = await saveAll()
expect(failed).toEqual([])
// Should use upsert since no customFieldValueId
expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith(
'cf-def-001',
'composant',
'cl-comp-1',
'220',
undefined,
)
})
it('returns failed field names on error', async () => {
mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false })
const defs = [mockCustomFieldDefs[0]!]
const vals = [mockCustomFieldValues[0]!]
const { saveAll } = useCustomFieldInputs({
definitions: ref(defs),
values: ref(vals),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const failed = await saveAll()
expect(failed).toEqual(['Tension nominale'])
})
})
// ---------------------------------------------------------------------------
// requiredFilled validation
// ---------------------------------------------------------------------------
describe('requiredFilled validation', () => {
it('returns true when required fields have values (including defaultValue)', () => {
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
// "Tension nominale" is required and has value '220'
expect(requiredFilled.value).toBe(true)
})
it('returns true when required field uses defaultValue', () => {
// No values provided — required field should use defaultValue '220'
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(requiredFilled.value).toBe(true)
})
it('returns false when required field has no value and no default', () => {
// Create a required field with no default and no value
const defs = [{
id: 'cf-required-no-default',
name: 'Required Field',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
}]
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(defs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(requiredFilled.value).toBe(false)
})
})

View File

@@ -0,0 +1,319 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockPostFormData = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
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(),
}),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const mockDocument = {
id: 'doc-001',
name: 'photo.jpg',
filename: 'photo.jpg',
mimeType: 'image/jpeg',
size: 12345,
fileUrl: '/files/photo.jpg',
downloadUrl: '/files/photo.jpg/download',
createdAt: '2025-01-10T08:00:00+00:00',
}
const mockDocument2 = {
id: 'doc-002',
name: 'schema.pdf',
filename: 'schema.pdf',
mimeType: 'application/pdf',
size: 54321,
fileUrl: '/files/schema.pdf',
downloadUrl: '/files/schema.pdf/download',
createdAt: '2025-01-11T09:00:00+00:00',
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockFile(name: string, type = 'image/jpeg'): File {
return new File(['content'], name, { type })
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// uploadDocuments — FormData is built correctly
// ---------------------------------------------------------------------------
describe('uploadDocuments', () => {
it('builds FormData with file and context fields', async () => {
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
const { uploadDocuments } = useDocuments()
const file = createMockFile('photo.jpg')
await uploadDocuments({
files: [file],
context: { pieceId: 'piece-001', composantId: 'comp-001' },
})
expect(mockPostFormData).toHaveBeenCalledTimes(1)
const [endpoint, formData] = mockPostFormData.mock.calls[0]!
expect(endpoint).toBe('/documents')
expect(formData).toBeInstanceOf(FormData)
expect(formData.get('file')).toBe(file)
expect(formData.get('name')).toBe('photo.jpg')
expect(formData.get('pieceId')).toBe('piece-001')
expect(formData.get('composantId')).toBe('comp-001')
})
it('uploads multiple files separately', async () => {
mockPostFormData
.mockResolvedValueOnce({ success: true, data: mockDocument })
.mockResolvedValueOnce({ success: true, data: mockDocument2 })
const { uploadDocuments } = useDocuments()
const file1 = createMockFile('photo.jpg')
const file2 = createMockFile('schema.pdf', 'application/pdf')
const result = await uploadDocuments({
files: [file1, file2],
context: { machineId: 'machine-001' },
})
expect(mockPostFormData).toHaveBeenCalledTimes(2)
// First call
const [, formData1] = mockPostFormData.mock.calls[0]!
expect(formData1.get('name')).toBe('photo.jpg')
expect(formData1.get('machineId')).toBe('machine-001')
// Second call
const [, formData2] = mockPostFormData.mock.calls[1]!
expect(formData2.get('name')).toBe('schema.pdf')
expect(formData2.get('machineId')).toBe('machine-001')
expect(result.success).toBe(true)
expect(Array.isArray(result.data) ? result.data : []).toHaveLength(2)
})
it('appends type to FormData when provided in context', async () => {
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
const { uploadDocuments } = useDocuments()
const file = createMockFile('facture.pdf', 'application/pdf')
await uploadDocuments({
files: [file],
context: { siteId: 'site-001', type: 'facture' },
})
const [, formData] = mockPostFormData.mock.calls[0]!
expect(formData.get('type')).toBe('facture')
expect(formData.get('siteId')).toBe('site-001')
})
it('returns error when no files provided', async () => {
const { uploadDocuments } = useDocuments()
const result = await uploadDocuments({ files: [], context: {} })
expect(result.success).toBe(false)
expect(mockPostFormData).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByComponent
// ---------------------------------------------------------------------------
describe('loadDocumentsByComponent', () => {
it('calls correct endpoint /documents/composant/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByComponent } = useDocuments()
const result = await loadDocumentsByComponent('comp-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/composant/comp-001')
expect(result.success).toBe(true)
})
it('returns error for empty componentId', async () => {
const { loadDocumentsByComponent } = useDocuments()
const result = await loadDocumentsByComponent('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByPiece
// ---------------------------------------------------------------------------
describe('loadDocumentsByPiece', () => {
it('calls correct endpoint /documents/piece/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByPiece } = useDocuments()
const result = await loadDocumentsByPiece('piece-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/piece/piece-001')
expect(result.success).toBe(true)
})
it('returns error for empty pieceId', async () => {
const { loadDocumentsByPiece } = useDocuments()
const result = await loadDocumentsByPiece('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByMachine
// ---------------------------------------------------------------------------
describe('loadDocumentsByMachine', () => {
it('calls correct endpoint /documents/machine/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByMachine } = useDocuments()
const result = await loadDocumentsByMachine('machine-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/machine/machine-001')
expect(result.success).toBe(true)
})
it('returns error for empty machineId', async () => {
const { loadDocumentsByMachine } = useDocuments()
const result = await loadDocumentsByMachine('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByProduct
// ---------------------------------------------------------------------------
describe('loadDocumentsByProduct', () => {
it('calls correct endpoint /documents/product/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByProduct } = useDocuments()
const result = await loadDocumentsByProduct('prod-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/product/prod-001')
expect(result.success).toBe(true)
})
it('returns error for empty productId', async () => {
const { loadDocumentsByProduct } = useDocuments()
const result = await loadDocumentsByProduct('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// deleteDocument
// ---------------------------------------------------------------------------
describe('deleteDocument', () => {
it('calls DELETE on correct endpoint', async () => {
mockDel.mockResolvedValue({ success: true })
const { deleteDocument } = useDocuments()
const result = await deleteDocument('doc-001')
expect(mockDel).toHaveBeenCalledTimes(1)
expect(mockDel).toHaveBeenCalledWith('/documents/doc-001')
expect(result.success).toBe(true)
})
it('removes from store when updateStore is true', async () => {
mockGet.mockResolvedValue({
success: true,
data: wrapCollection([mockDocument, mockDocument2]),
})
mockDel.mockResolvedValue({ success: true })
const { loadDocuments, deleteDocument, documents } = useDocuments()
// Load documents into store first
await loadDocuments({ force: true })
expect(documents.value).toHaveLength(2)
// Delete with updateStore: true
await deleteDocument('doc-001', { updateStore: true })
expect(documents.value).toHaveLength(1)
expect(documents.value[0]!.id).toBe('doc-002')
})
it('shows success toast on successful delete', async () => {
mockDel.mockResolvedValue({ success: true })
const { deleteDocument } = useDocuments()
await deleteDocument('doc-001')
expect(mockShowSuccess).toHaveBeenCalledWith('Document supprimé')
})
})

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import {
mockMachineCustomFieldDefs,
mockMachineCustomFieldValues,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockUpdateCustomFieldValue = vi.fn()
const mockUpsertCustomFieldValue = vi.fn()
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
updateCustomFieldValue: mockUpdateCustomFieldValue,
upsertCustomFieldValue: mockUpsertCustomFieldValue,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
mockUpsertCustomFieldValue.mockResolvedValue({
success: true,
data: { id: 'new-mcfv-id', customField: { id: 'new-mcf-id' } },
})
})
// ---------------------------------------------------------------------------
// Helper — create composable with machine context (no context filter)
// ---------------------------------------------------------------------------
function createMachineFields(
defs = mockMachineCustomFieldDefs,
vals = mockMachineCustomFieldValues,
entityId = 'cl-machine-1',
) {
return useCustomFieldInputs({
definitions: ref(defs),
values: ref(vals),
entityType: 'machine',
entityId: ref(entityId),
// No context — machine custom fields don't use machineContextOnly filtering
})
}
// ---------------------------------------------------------------------------
// Machine custom field initialization
// ---------------------------------------------------------------------------
describe('machine custom field initialization', () => {
it('loads all machine custom fields with values (5 fields)', () => {
const { fields } = createMachineFields()
expect(fields.value).toHaveLength(5)
})
it('preserves text value (Numéro de série)', () => {
const { fields } = createMachineFields()
const textField = fields.value.find(f => f.name === 'Numéro de série')
expect(textField?.value).toBe('SN-2025-001234')
expect(textField?.type).toBe('text')
})
it('preserves boolean value (En service = true)', () => {
const { fields } = createMachineFields()
const boolField = fields.value.find(f => f.name === 'En service')
expect(boolField?.value).toBe('true')
expect(boolField?.type).toBe('boolean')
})
it('preserves number zero value (Puissance kW = 0)', () => {
const { fields } = createMachineFields()
const numField = fields.value.find(f => f.name === 'Puissance (kW)')
expect(numField?.value).toBe('0')
expect(numField?.type).toBe('number')
})
it('preserves select value (Catégorie ATEX = Zone 1)', () => {
const { fields } = createMachineFields()
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')
expect(selectField?.value).toBe('Zone 1')
expect(selectField?.type).toBe('select')
expect(selectField?.options).toEqual(['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'])
})
it('preserves date value (Date mise en service = 2025-01-15)', () => {
const { fields } = createMachineFields()
const dateField = fields.value.find(f => f.name === 'Date mise en service')
expect(dateField?.value).toBe('2025-01-15')
expect(dateField?.type).toBe('date')
})
})
// ---------------------------------------------------------------------------
// Boolean checkbox — the critical test
// ---------------------------------------------------------------------------
describe('boolean checkbox — the critical test', () => {
it('toggle true to false sends "false" (not deleted) via update()', async () => {
const { fields, update } = createMachineFields()
const boolField = fields.value.find(f => f.name === 'En service')!
expect(boolField.value).toBe('true')
// Toggle to false
boolField.value = 'false'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
})
it('toggle false to true sends "true"', async () => {
// Start with boolean value = false
const falseVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
const vals = mockMachineCustomFieldValues.map((v, i) => (i === 1 ? falseVal : v))
const { fields, update } = createMachineFields(mockMachineCustomFieldDefs, vals)
const boolField = fields.value.find(f => f.name === 'En service')!
expect(boolField.value).toBe('false')
// Toggle to true
boolField.value = 'true'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'true' })
})
it('boolean false is persisted in saveAll (not skipped)', async () => {
// Only the boolean field with value "false"
const boolDef = mockMachineCustomFieldDefs[1]!
const boolVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
const { fields, saveAll } = createMachineFields([boolDef], [boolVal])
expect(fields.value[0]?.value).toBe('false')
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
})
})
// ---------------------------------------------------------------------------
// Number zero — not lost
// ---------------------------------------------------------------------------
describe('number zero — not lost', () => {
it('preserves zero value after load', () => {
const { fields } = createMachineFields()
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
expect(numField.value).toBe('0')
})
it('saves zero value (not skipped) in saveAll', async () => {
// Only the number field with value "0"
const numDef = mockMachineCustomFieldDefs[2]!
const numVal = mockMachineCustomFieldValues[2]!
const { saveAll } = createMachineFields([numDef], [numVal])
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
})
})
// ---------------------------------------------------------------------------
// Select field
// ---------------------------------------------------------------------------
describe('select field', () => {
it('preserves selected option', () => {
const { fields } = createMachineFields()
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
expect(selectField.value).toBe('Zone 1')
})
it('uses defaultValue when no value exists', () => {
// Use defs with a select that has a defaultValue
const defsWithDefault = mockMachineCustomFieldDefs.map((d, i) =>
i === 3 ? { ...d, defaultValue: 'Non classé' } : d,
)
// No values for the select field
const valsWithoutSelect = mockMachineCustomFieldValues.filter(
v => v.customField.name !== 'Catégorie ATEX',
)
const { fields } = createMachineFields(defsWithDefault, valsWithoutSelect)
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
expect(selectField.value).toBe('Non classé')
})
})
// ---------------------------------------------------------------------------
// Field isolation
// ---------------------------------------------------------------------------
describe('field isolation', () => {
it('updating one field does not change other field values', async () => {
const { fields, update } = createMachineFields()
// Snapshot original values
const originalValues = fields.value.map(f => ({ name: f.name, value: f.value }))
// Update only the text field
const textField = fields.value.find(f => f.name === 'Numéro de série')!
textField.value = 'SN-UPDATED-999'
await update(textField)
// All other fields should still have their original values
for (const field of fields.value) {
if (field.name === 'Numéro de série') continue
const original = originalValues.find(o => o.name === field.name)
expect(field.value).toBe(original?.value)
}
})
it('saveAll preserves all field values even on partial failure', async () => {
// Make the second call fail (boolean field)
mockUpdateCustomFieldValue
.mockResolvedValueOnce({ success: true }) // text — Numéro de série
.mockResolvedValueOnce({ success: false }) // boolean — En service
.mockResolvedValue({ success: true }) // rest succeed
const { fields, saveAll } = createMachineFields()
// Snapshot values before saveAll
const valuesBefore = fields.value.map(f => ({ name: f.name, value: f.value }))
const failed = await saveAll()
// Only the boolean field should have failed
expect(failed).toEqual(['En service'])
// All field values should still be intact (not cleared or corrupted)
for (const field of fields.value) {
const before = valuesBefore.find(v => v.name === field.name)
expect(field.value).toBe(before?.value)
}
})
})

View File

@@ -0,0 +1,700 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
// ---------------------------------------------------------------------------
// Mock data — realistic /machines/{id}/structure response
// ---------------------------------------------------------------------------
const MACHINE_ID = 'cl-machine-abc123'
const SITE_ID = 'cl-site-nord-001'
const COMPONENT_LINK_ID = 'cl-mcl-001'
const PIECE_LINK_ID = 'cl-mpl-001'
const PRODUCT_LINK_ID = 'cl-mprl-001'
const COMPOSANT_ID = 'cl-comp-moteur-001'
const PIECE_ID = 'cl-piece-roul-001'
const PRODUCT_ID = 'cl-prod-graisse-001'
const CONSTRUCTEUR_ID = 'cstr-skf-001'
const mockConstructeurSKF = {
id: CONSTRUCTEUR_ID,
name: 'SKF',
email: 'contact@skf.com',
phone: '+33 1 23 45 67 89',
}
const mockStructureResponse = {
success: true,
data: {
machine: {
id: MACHINE_ID,
name: 'Presse hydraulique PH-200',
reference: 'MACH-PH-200',
prix: 150000,
siteId: SITE_ID,
site: { id: SITE_ID, name: 'Usine Nord' },
documents: [{ id: 'doc-001', name: 'Manuel PH-200.pdf', type: 'manual' }],
customFieldValues: [
{
id: 'mcfv-001',
value: 'SN-2025-PH200',
customField: {
id: 'mcf-001',
name: 'Serial Number',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
},
],
customFields: [
{
id: 'mcf-001',
name: 'Serial Number',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
],
constructeurs: [
{
id: 'cl-mconst-001',
constructeur: mockConstructeurSKF,
supplierReference: 'SKF-PH200',
},
],
},
componentLinks: [
{
id: COMPONENT_LINK_ID,
composant: {
id: COMPOSANT_ID,
name: 'Moteur principal',
reference: 'COMP-MOT-001',
prix: 12500,
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
constructeurs: [mockConstructeurSKF],
constructeurIds: [CONSTRUCTEUR_ID],
documents: [],
customFields: [
{
definitionId: 'cf-comp-001',
name: 'Tension nominale',
type: 'number',
value: '380',
},
],
customFieldValues: [],
},
overrides: {
name: 'Moteur principal PH-200',
reference: 'COMP-MOT-PH200',
prix: 13000,
},
contextCustomFields: [
{
id: 'ctx-cf-001',
name: 'Position sur machine',
type: 'text',
machineContextOnly: true,
},
],
contextCustomFieldValues: [
{
id: 'ctx-cfv-001',
value: 'Bloc moteur gauche',
customField: {
id: 'ctx-cf-001',
name: 'Position sur machine',
type: 'text',
machineContextOnly: true,
},
},
],
pieceLinks: [
{
id: PIECE_LINK_ID,
piece: {
id: PIECE_ID,
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 45.90,
typePiece: { id: 'tp-bearing', name: 'Roulement' },
constructeurs: [mockConstructeurSKF],
documents: [],
customFields: [],
},
overrides: {
name: 'Roulement 6205-RS',
},
quantity: 2,
parentComponentLinkId: COMPONENT_LINK_ID,
contextCustomFields: [],
contextCustomFieldValues: [
{
id: 'ctx-cfv-piece-001',
value: 'Cote entrainement',
customField: {
id: 'ctx-cf-piece-001',
name: 'Emplacement',
type: 'text',
machineContextOnly: true,
},
},
],
},
],
childLinks: [],
},
],
pieceLinks: [],
productLinks: [
{
id: PRODUCT_LINK_ID,
product: {
id: PRODUCT_ID,
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
prix: 45.90,
},
overrides: null,
},
],
},
}
// Response with NO overrides — for fallback testing
const mockStructureNoOverrides = {
success: true,
data: {
machine: {
...mockStructureResponse.data.machine,
},
componentLinks: [
{
id: COMPONENT_LINK_ID,
composant: {
id: COMPOSANT_ID,
name: 'Moteur principal',
reference: 'COMP-MOT-001',
prix: 12500,
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
constructeurs: [],
documents: [],
customFields: [],
customFieldValues: [],
},
overrides: null,
contextCustomFields: [],
contextCustomFieldValues: [],
pieceLinks: [
{
id: PIECE_LINK_ID,
piece: {
id: PIECE_ID,
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 45.90,
typePiece: { id: 'tp-bearing', name: 'Roulement' },
constructeurs: [],
documents: [],
customFields: [],
},
overrides: null,
quantity: 1,
parentComponentLinkId: COMPONENT_LINK_ID,
contextCustomFields: [],
contextCustomFieldValues: [],
},
],
childLinks: [],
},
],
pieceLinks: [],
productLinks: [],
},
}
// ---------------------------------------------------------------------------
// Mocks — all composables used by useMachineDetailData
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockPost = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
patch: mockPatch,
post: mockPost,
delete: mockDel,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useMachines', () => ({
useMachines: () => ({
updateMachine: vi.fn().mockResolvedValue({ success: true }),
updateStructure: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
updateComposant: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
updatePiece: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: ref([
{ id: 'tc-moteur', name: 'Moteur electrique' },
]),
loadComponentTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: ref([
{ id: 'tp-bearing', name: 'Roulement' },
]),
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
upsertCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
updateCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
constructeurs: ref([mockConstructeurSKF]),
loadConstructeurs: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useSites', () => ({
useSites: () => ({
sites: ref([{ id: SITE_ID, name: 'Usine Nord' }]),
loadSites: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: ref([
{ id: PRODUCT_ID, name: 'Graisse LGMT2', reference: 'LUB-LGMT2', prix: 45.90 },
]),
loadProducts: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
uploadDocuments: vi.fn().mockResolvedValue({ success: true }),
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
loadDocumentsByMachine: vi.fn().mockResolvedValue({ success: true, data: [] }),
loadDocumentsByProduct: vi.fn().mockResolvedValue({ success: true, data: [] }),
}),
}))
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: vi.fn().mockResolvedValue([]),
syncLinks: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/utils/printTemplates/machineReport', () => ({
buildMachinePrintContext: vi.fn(),
buildMachinePrintHtml: vi.fn().mockReturnValue('<html></html>'),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: vi.fn().mockReturnValue(false),
}))
vi.mock('~/shared/utils/documentDisplayUtils', () => ({
downloadDocument: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue(mockStructureResponse)
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function loadAndReturn(responseOverride?: unknown) {
if (responseOverride) {
mockGet.mockResolvedValue(responseOverride)
}
const result = useMachineDetailData(MACHINE_ID)
await result.loadMachineData()
return result
}
// ===========================================================================
// 1. Hierarchy loading
// ===========================================================================
describe('hierarchy loading', () => {
it('loads machine with all core fields', async () => {
const { machine, machineName, machineReference, machineSiteId } = await loadAndReturn()
expect(machine.value).not.toBeNull()
expect(machine.value!.id).toBe(MACHINE_ID)
expect(machine.value!.name).toBe('Presse hydraulique PH-200')
expect(machine.value!.reference).toBe('MACH-PH-200')
expect(machine.value!.prix).toBe(150000)
expect(machineName.value).toBe('Presse hydraulique PH-200')
expect(machineReference.value).toBe('MACH-PH-200')
expect(machineSiteId.value).toBe(SITE_ID)
})
it('calls GET /machines/{id}/structure', async () => {
await loadAndReturn()
expect(mockGet).toHaveBeenCalledWith(`/machines/${MACHINE_ID}/structure`)
})
it('loads componentLinks from structure response', async () => {
const { machineComponentLinks } = await loadAndReturn()
expect(machineComponentLinks.value).toHaveLength(1)
expect(machineComponentLinks.value[0]!.id).toBe(COMPONENT_LINK_ID)
})
it('builds component hierarchy with composant data', async () => {
const { components } = await loadAndReturn()
expect(components.value.length).toBeGreaterThanOrEqual(1)
const comp = components.value[0]!
expect(comp.composantId).toBe(COMPOSANT_ID)
})
it('loads piece links nested under their parent componentLink', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const pieces = comp.pieces as Record<string, unknown>[]
expect(pieces).toBeDefined()
expect(pieces.length).toBeGreaterThanOrEqual(1)
const piece = pieces[0]!
expect(piece.pieceId).toBe(PIECE_ID)
expect(piece.parentComponentLinkId).toBe(COMPONENT_LINK_ID)
})
it('preserves piece quantity', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const pieces = comp.pieces as Record<string, unknown>[]
const piece = pieces[0]!
expect(piece.quantity).toBe(2)
})
it('loads product links at machine level', async () => {
const { machineProductLinks } = await loadAndReturn()
expect(machineProductLinks.value).toHaveLength(1)
expect(machineProductLinks.value[0]!.id).toBe(PRODUCT_LINK_ID)
})
it('preserves machine documents', async () => {
const { machine } = await loadAndReturn()
const docs = machine.value!.documents as unknown[]
expect(docs).toHaveLength(1)
})
it('preserves machine customFieldValues', async () => {
const { machine } = await loadAndReturn()
const cfv = machine.value!.customFieldValues as Record<string, unknown>[]
expect(cfv).toHaveLength(1)
expect((cfv[0] as any).value).toBe('SN-2025-PH200')
})
it('sets loading to false after data load', async () => {
const { loading } = await loadAndReturn()
expect(loading.value).toBe(false)
})
it('handles failed API response gracefully', async () => {
const { machine, components, pieces } = await loadAndReturn({
success: false,
error: 'Not found',
})
expect(machine.value).toBeNull()
expect(components.value).toEqual([])
expect(pieces.value).toEqual([])
})
it('handles invalid machine payload gracefully', async () => {
const { machine } = await loadAndReturn({
success: true,
data: null,
})
expect(machine.value).toBeNull()
})
})
// ===========================================================================
// 2. Overrides
// ===========================================================================
describe('overrides on component links', () => {
it('uses nameOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.name).toBe('Moteur principal PH-200')
})
it('falls back to composant.name when nameOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.name).toBe('Moteur principal')
})
it('uses referenceOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.reference).toBe('COMP-MOT-PH200')
})
it('falls back to composant.reference when referenceOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.reference).toBe('COMP-MOT-001')
})
it('uses prixOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.prix).toBe(13000)
})
it('falls back to composant.prix when prixOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.prix).toBe(12500)
})
})
describe('overrides on piece links', () => {
it('uses piece nameOverride when present', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.name).toBe('Roulement 6205-RS')
})
it('falls back to piece.name when nameOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.name).toBe('Roulement 6205')
})
it('preserves piece reference from underlying entity when no override', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
// The override only has name, so reference comes from the piece entity
expect(piece.reference).toBe('ROUL-6205')
})
it('preserves piece prix from underlying entity when no override', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.prix).toBe(45.90)
})
})
// ===========================================================================
// 3. Custom field values on links (context fields)
// ===========================================================================
describe('contextCustomFieldValues on component links', () => {
it('loads contextCustomFieldValues on component hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const ctxValues = comp.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toBeDefined()
expect(ctxValues).toHaveLength(1)
expect((ctxValues[0] as any).value).toBe('Bloc moteur gauche')
expect((ctxValues[0] as any).customField.name).toBe('Position sur machine')
})
it('loads contextCustomFields definitions on component hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const ctxFields = comp.contextCustomFields as Record<string, unknown>[]
expect(ctxFields).toBeDefined()
expect(ctxFields).toHaveLength(1)
expect((ctxFields[0] as any).name).toBe('Position sur machine')
expect((ctxFields[0] as any).machineContextOnly).toBe(true)
})
})
describe('contextCustomFieldValues on piece links', () => {
it('loads contextCustomFieldValues on piece hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toBeDefined()
expect(ctxValues).toHaveLength(1)
expect((ctxValues[0] as any).value).toBe('Cote entrainement')
})
it('has empty contextCustomFieldValues when none provided', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toEqual([])
})
})
// ===========================================================================
// 4. Constructeur links on machine
// ===========================================================================
describe('constructeur links on machine', () => {
it('parses constructeur links from machine data', async () => {
const { constructeurLinks } = await loadAndReturn()
expect(constructeurLinks.value).toHaveLength(1)
expect(constructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
expect(constructeurLinks.value[0]!.supplierReference).toBe('SKF-PH200')
})
it('populates machineConstructeurIds from links', async () => {
const { machineConstructeurIds } = await loadAndReturn()
expect(machineConstructeurIds.value).toContain(CONSTRUCTEUR_ID)
})
it('stores original constructeur links for cancel rollback', async () => {
const { originalConstructeurLinks } = await loadAndReturn()
expect(originalConstructeurLinks.value).toHaveLength(1)
expect(originalConstructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
})
it('hasMachineConstructeur is true when constructeur present', async () => {
const { hasMachineConstructeur } = await loadAndReturn()
expect(hasMachineConstructeur.value).toBe(true)
})
it('resolves constructeur display objects', async () => {
const { machineConstructeursDisplay } = await loadAndReturn()
expect(machineConstructeursDisplay.value.length).toBeGreaterThanOrEqual(1)
const display = machineConstructeursDisplay.value[0] as any
expect(display.name).toBe('SKF')
})
})
// ===========================================================================
// 5. Site (required)
// ===========================================================================
describe('site loaded with machine data', () => {
it('machineSiteId is populated from machine payload', async () => {
const { machineSiteId } = await loadAndReturn()
expect(machineSiteId.value).toBe(SITE_ID)
})
it('sites ref is available for dropdowns', async () => {
const { sites } = await loadAndReturn()
expect(sites.value).toHaveLength(1)
expect((sites.value[0] as any).name).toBe('Usine Nord')
})
it('machine.site object is preserved in machine ref', async () => {
const { machine } = await loadAndReturn()
const site = machine.value!.site as Record<string, unknown>
expect(site.id).toBe(SITE_ID)
expect(site.name).toBe('Usine Nord')
})
})
// ===========================================================================
// 6. UI state defaults
// ===========================================================================
describe('UI state defaults', () => {
it('isEditMode starts as false', () => {
const { isEditMode } = useMachineDetailData(MACHINE_ID)
expect(isEditMode.value).toBe(false)
})
it('saving starts as false', () => {
const { saving } = useMachineDetailData(MACHINE_ID)
expect(saving.value).toBe(false)
})
it('loading starts as true', () => {
const { loading } = useMachineDetailData(MACHINE_ID)
expect(loading.value).toBe(true)
})
})

View File

@@ -0,0 +1,644 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
mockPieceFromApi,
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
mockConstructeurFAG,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
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(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces (updatePiece)
// ---------------------------------------------------------------------------
const mockUpdatePiece = vi.fn()
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
updatePiece: mockUpdatePiece,
pieces: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieceTypes
// ---------------------------------------------------------------------------
const mockPieceTypes = { value: [] as any[] }
const mockLoadPieceTypes = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: mockPieceTypes,
loadPieceTypes: mockLoadPieceTypes,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments
// ---------------------------------------------------------------------------
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByPiece: vi.fn().mockResolvedValue({ success: true, data: [] }),
uploadDocuments: vi.fn().mockResolvedValue({ success: true, data: [] }),
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks
// ---------------------------------------------------------------------------
const mockFetchLinks = vi.fn().mockResolvedValue([])
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: mockFetchLinks,
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useEntityHistory
// ---------------------------------------------------------------------------
vi.mock('~/composables/useEntityHistory', () => ({
useEntityHistory: () => ({
history: { value: [] },
loading: { value: false },
error: { value: null },
loadHistory: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils
// ---------------------------------------------------------------------------
vi.mock('~/shared/modelUtils', () => ({
formatPieceStructurePreview: () => '',
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
vi.mock('~/services/modelTypes', () => ({
getModelType: vi.fn().mockResolvedValue(null),
}))
vi.mock('~/shared/apiRelations', () => ({
extractRelationId: (rel: any) => {
if (typeof rel === 'string') return rel
if (rel && typeof rel === 'object' && 'id' in rel) return rel.id
return null
},
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const PIECE_ID = 'piece-001'
const mockPieceType = {
id: 'tp-bearing-001',
name: 'Roulement',
code: 'ROUL',
category: 'PIECE',
structure: {
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
familyCode: 'LUB',
role: 'lubrification',
},
],
customFields: [],
},
}
function buildPieceWithProducts() {
return {
...mockPieceFromApi,
id: PIECE_ID,
'@id': `/api/pieces/${PIECE_ID}`,
description: 'Roulement haute performance',
prix: '42.50',
typePieceId: 'tp-bearing-001',
productIds: ['prod-001'],
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const tick = () => new Promise(r => setTimeout(r, 0))
async function createAndHydrate(overrides?: Record<string, any>) {
const pieceData = { ...buildPieceWithProducts(), ...overrides }
mockGet.mockImplementation((url: string) => {
if (url.includes(`/pieces/${PIECE_ID}`)) {
return Promise.resolve({ success: true, data: structuredClone(pieceData) })
}
return Promise.resolve({ success: true, data: wrapCollection([]) })
})
mockFetchLinks.mockResolvedValue([
{ ...mockLinkSKF },
{ ...mockLinkFAG },
])
const composable = usePieceEdit(PIECE_ID)
await composable.fetchPiece()
await tick()
return composable
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockPieceTypes.value = [mockPieceType]
})
// ---------------------------------------------------------------------------
// fetchPiece — hydration
// ---------------------------------------------------------------------------
describe('fetchPiece — hydration', () => {
it('loads all simple fields (name, reference, description, prix)', async () => {
const composable = await createAndHydrate()
expect(composable.editionForm.name).toBe('Roulement 6205')
expect(composable.editionForm.reference).toBe('ROUL-6205')
expect(composable.editionForm.description).toBe('Roulement haute performance')
expect(composable.editionForm.prix).toBe('42.50')
})
it('loads piece with product slots', async () => {
const composable = await createAndHydrate()
expect(composable.piece.value).not.toBeNull()
expect(composable.piece.value.productSlots).toHaveLength(1)
expect(composable.piece.value.productSlots[0].product.id).toBe('prod-001')
})
it('loads constructeur links via fetchLinks', async () => {
const composable = await createAndHydrate()
expect(mockFetchLinks).toHaveBeenCalledWith('piece', PIECE_ID)
expect(composable.constructeurLinks.value).toHaveLength(2)
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(composable.constructeurLinks.value[1].constructeurId).toBe(mockConstructeurFAG.id)
})
})
// ---------------------------------------------------------------------------
// Product selections
// ---------------------------------------------------------------------------
describe('product selections', () => {
it('setProductSelection updates the correct index', async () => {
const composable = await createAndHydrate()
// The structure has 1 product requirement, so productSelections should have 1 entry
composable.setProductSelection(0, 'prod-new-001')
await tick()
expect(composable.productSelections.value[0]).toBe('prod-new-001')
})
it('setProductSelection to null does not crash', async () => {
const composable = await createAndHydrate()
// Set then clear
composable.setProductSelection(0, 'prod-001')
await tick()
composable.setProductSelection(0, null)
await tick()
expect(composable.productSelections.value[0]).toBeNull()
})
})
// ---------------------------------------------------------------------------
// submitEdition — no data loss
// ---------------------------------------------------------------------------
describe('submitEdition — no data loss', () => {
it('sends all form fields in update payload', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.name = 'Roulement modifie'
composable.editionForm.description = 'Nouvelle description'
composable.editionForm.reference = 'REF-MOD-001'
composable.editionForm.prix = '99.99'
// Ensure product selection is filled so submit proceeds
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload).toMatchObject({
name: 'Roulement modifie',
description: 'Nouvelle description',
reference: 'REF-MOD-001',
prix: '99.99',
})
})
it('saves custom fields after piece update', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('syncs constructeur links', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(entityType).toBe('piece')
expect(entityId).toBe(PIECE_ID)
expect(origLinks).toHaveLength(2)
expect(formLinks).toHaveLength(2)
})
it('editing name does not lose constructeur links', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
// Only edit name
composable.editionForm.name = 'Nouveau nom piece'
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.name).toBe('Nouveau nom piece')
// syncLinks still called with constructeur links preserved
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(origLinks).toHaveLength(2)
expect(formLinks).toHaveLength(2)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
})
it('editing name does not lose product slots', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
// Set product selection
composable.setProductSelection(0, 'prod-001')
await tick()
// Now edit only name
composable.editionForm.name = 'Autre nom'
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.name).toBe('Autre nom')
// productIds should still contain the selection
expect(payload.productIds).toContain('prod-001')
})
it('adding a constructeur preserves existing ones', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
// Initially has SKF + FAG from fetchLinks
expect(composable.constructeurLinks.value).toHaveLength(2)
// Add a third constructeur
const newLink = {
linkId: null as string | null,
constructeurId: 'cstr-new-003',
constructeur: { id: 'cstr-new-003', name: 'NEW Corp', email: null, phone: null },
supplierReference: 'NEW-REF-001',
}
composable.constructeurLinks.value = [
...composable.constructeurLinks.value,
newLink,
]
await tick()
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
// Original had 2 (SKF + FAG)
expect(origLinks).toHaveLength(2)
// Form now has 3 (SKF + FAG + NEW)
expect(formLinks).toHaveLength(3)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
expect(formLinks[2].constructeurId).toBe('cstr-new-003')
})
it('sends both productId and productIds in payload', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.productId).toBe('prod-001')
expect(payload.productIds).toEqual(['prod-001'])
})
it('productId is the first product selection when multiple exist', async () => {
// Override the piece type to have 2 product requirements
const multiProductType = {
...mockPieceType,
structure: {
...mockPieceType.structure,
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
familyCode: 'LUB',
role: 'lubrification',
},
{
typeProductId: 'tprod-oil-002',
typeProductLabel: 'Huile',
familyCode: 'LUB',
role: 'lubrification secondaire',
},
],
customFields: [],
},
}
mockPieceTypes.value = [multiProductType]
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate({
productIds: ['prod-001', 'prod-002'],
})
composable.setProductSelection(0, 'prod-001')
composable.setProductSelection(1, 'prod-002')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.productId).toBe('prod-001')
expect(payload.productIds).toEqual(['prod-001', 'prod-002'])
})
})
// ---------------------------------------------------------------------------
// submitEdition — null field handling
// ---------------------------------------------------------------------------
describe('submitEdition — null field handling', () => {
it('empty prix sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ''
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('whitespace-only prix sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ' '
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('empty reference sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.reference = ''
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.reference).toBeNull()
})
it('valid prix is sent as string number', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = '99.50'
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBe('99.5')
})
})
// ---------------------------------------------------------------------------
// submitEdition — error paths
// ---------------------------------------------------------------------------
describe('submitEdition — error paths', () => {
it('does not save custom fields when updatePiece fails', async () => {
mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('does not save custom fields when updatePiece throws', async () => {
mockUpdatePiece.mockRejectedValue(new Error('Network failure'))
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Network failure')
})
it('shows error toast when product selection is not filled', async () => {
const composable = await createAndHydrate()
// Clear product selection
composable.setProductSelection(0, null)
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.')
})
})

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePieces } from '~/composables/usePieces'
import { mockPieceFromApi, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearPiecesCache } = usePieces()
clearPiecesCache()
})
// ---------------------------------------------------------------------------
// createPiece
// ---------------------------------------------------------------------------
describe('createPiece', () => {
it('sends all fields including prix in POST payload', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece } = usePieces()
await createPiece({
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 25.50,
typePieceId: 'tp-bearing-001',
} as any)
expect(mockPost).toHaveBeenCalledWith('/pieces', expect.objectContaining({
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 25.50,
typePiece: '/api/model_types/tp-bearing-001',
}))
})
it('strips constructeur fields from payload', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece } = usePieces()
await createPiece({
name: 'Test Piece',
constructeurIds: ['cstr-skf-001'],
constructeurs: [{ id: 'cstr-skf-001', name: 'SKF' }] as any,
})
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('constructeurIds')
expect(payload).not.toHaveProperty('constructeurs')
expect(payload).not.toHaveProperty('constructeurId')
expect(payload).not.toHaveProperty('constructeur')
})
it('adds created piece to cache (pieces array and total)', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece, pieces, total } = usePieces()
const result = await createPiece({ name: 'New Piece' })
expect(result.success).toBe(true)
expect(pieces.value).toHaveLength(1)
expect(pieces.value[0]!.id).toBe('piece-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updatePiece
// ---------------------------------------------------------------------------
describe('updatePiece', () => {
it('patches with supplied fields and updates cache', async () => {
// Seed cache first
const original = { ...mockPieceFromApi }
mockPost.mockResolvedValue({ success: true, data: original })
const { createPiece, updatePiece, pieces } = usePieces()
await createPiece({ name: 'Roulement 6205' })
const updated = { ...mockPieceFromApi, name: 'Updated Name', reference: 'ROUL-NEW' }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updatePiece(mockPieceFromApi.id, {
name: 'Updated Name',
reference: 'ROUL-NEW',
})
expect(mockPatch).toHaveBeenCalledWith(`/pieces/${mockPieceFromApi.id}`, expect.objectContaining({
name: 'Updated Name',
reference: 'ROUL-NEW',
}))
expect(result.success).toBe(true)
expect(pieces.value.find(p => p.id === mockPieceFromApi.id)?.name).toBe('Updated Name')
})
})
// ---------------------------------------------------------------------------
// deletePiece
// ---------------------------------------------------------------------------
describe('deletePiece', () => {
it('removes piece from cache on success', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
const { createPiece, deletePiece, pieces, total } = usePieces()
await createPiece({ name: 'To Delete' })
expect(pieces.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: true })
const result = await deletePiece(mockPieceFromApi.id)
expect(result.success).toBe(true)
expect(pieces.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('does not remove on failure', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
const { createPiece, deletePiece, pieces, total } = usePieces()
await createPiece({ name: 'Should Stay' })
expect(pieces.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deletePiece(mockPieceFromApi.id)
expect(result.success).toBe(false)
expect(pieces.value).toHaveLength(1)
expect(total.value).toBe(1)
})
})

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProducts } from '~/composables/useProducts'
import { mockProductFromApi, mockConstructeurSKF, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearProductsCache } = useProducts()
clearProductsCache()
})
// ---------------------------------------------------------------------------
// createProduct
// ---------------------------------------------------------------------------
describe('createProduct', () => {
it('sends all fields including supplierPrice in POST payload', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct } = useProducts()
await createProduct({
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
supplierPrice: 45.90,
typeProductId: 'tprod-grease-001',
})
expect(mockPost).toHaveBeenCalledWith('/products', expect.objectContaining({
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
supplierPrice: 45.90,
typeProduct: '/api/model_types/tprod-grease-001',
}))
})
it('strips constructeur fields from payload', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct } = useProducts()
await createProduct({
name: 'Test Product',
constructeurIds: ['cstr-skf-001'],
constructeurs: [mockConstructeurSKF] as any,
})
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('constructeurIds')
expect(payload).not.toHaveProperty('constructeurs')
expect(payload).not.toHaveProperty('constructeurId')
expect(payload).not.toHaveProperty('constructeur')
})
it('adds created product to cache (products array and total)', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct, products, total } = useProducts()
const result = await createProduct({ name: 'New Product' })
expect(result.success).toBe(true)
expect(products.value).toHaveLength(1)
expect(products.value[0]!.id).toBe('prod-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updateProduct
// ---------------------------------------------------------------------------
describe('updateProduct', () => {
it('patches with supplied fields and updates cache', async () => {
// Seed cache first
const original = { ...mockProductFromApi }
mockPost.mockResolvedValue({ success: true, data: original })
const { createProduct, updateProduct, products } = useProducts()
await createProduct({ name: 'Graisse LGMT2' })
const updated = { ...mockProductFromApi, name: 'Updated Name', supplierPrice: 99.99 }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updateProduct(mockProductFromApi.id, {
name: 'Updated Name',
supplierPrice: 99.99,
})
expect(mockPatch).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`, expect.objectContaining({
name: 'Updated Name',
supplierPrice: 99.99,
}))
expect(result.success).toBe(true)
expect(products.value.find(p => p.id === mockProductFromApi.id)?.name).toBe('Updated Name')
})
})
// ---------------------------------------------------------------------------
// deleteProduct
// ---------------------------------------------------------------------------
describe('deleteProduct', () => {
it('removes product from cache on success', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
const { createProduct, deleteProduct, products, total } = useProducts()
await createProduct({ name: 'To Delete' })
expect(products.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: true })
const result = await deleteProduct(mockProductFromApi.id)
expect(result.success).toBe(true)
expect(products.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('does not remove on failure', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
const { createProduct, deleteProduct, products, total } = useProducts()
await createProduct({ name: 'Should Stay' })
expect(products.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deleteProduct(mockProductFromApi.id)
expect(result.success).toBe(false)
expect(products.value).toHaveLength(1)
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// getProduct
// ---------------------------------------------------------------------------
describe('getProduct', () => {
it('returns cached product if available with constructeurs (no extra API call)', async () => {
// Seed cache with a product that has resolved constructeurs
const productWithConstructeurs = {
...mockProductFromApi,
constructeurs: [mockConstructeurSKF],
}
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
const { createProduct, getProduct } = useProducts()
await createProduct({ name: 'Cached' })
mockGet.mockClear()
const result = await getProduct(mockProductFromApi.id)
expect(result.success).toBe(true)
expect(result.data?.id).toBe(mockProductFromApi.id)
expect(mockGet).not.toHaveBeenCalled()
})
it('fetches from API with force: true', async () => {
// Seed cache with a product that has resolved constructeurs
const productWithConstructeurs = {
...mockProductFromApi,
constructeurs: [mockConstructeurSKF],
}
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
const { createProduct, getProduct } = useProducts()
await createProduct({ name: 'Cached' })
const freshData = { ...mockProductFromApi, name: 'Fresh from API' }
mockGet.mockResolvedValue({ success: true, data: freshData })
const result = await getProduct(mockProductFromApi.id, { force: true })
expect(mockGet).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`)
expect(result.success).toBe(true)
expect(result.data?.name).toBe('Fresh from API')
})
})

438
frontend/tests/fixtures/mockData.ts vendored Normal file
View File

@@ -0,0 +1,438 @@
// ---------------------------------------------------------------------------
// Shared mock data for Inventory frontend test suite
// ---------------------------------------------------------------------------
import type { ConstructeurLinkEntry, ConstructeurSummary } from '~/shared/constructeurUtils'
import type { CustomFieldDefinition, CustomFieldValue } from '~/shared/utils/customFields'
import type { ComponentModelStructure } from '~/shared/types/inventory'
// ---------------------------------------------------------------------------
// Constructeurs
// ---------------------------------------------------------------------------
export const mockConstructeurSKF: ConstructeurSummary = {
id: 'cstr-skf-001',
name: 'SKF',
email: 'contact@skf.com',
phone: '+33 1 23 45 67 89',
}
export const mockConstructeurFAG: ConstructeurSummary = {
id: 'cstr-fag-002',
name: 'FAG',
email: 'info@fag.de',
phone: '+49 9721 91 0',
}
// ---------------------------------------------------------------------------
// Constructeur link entries
// ---------------------------------------------------------------------------
export const mockLinkSKF: ConstructeurLinkEntry = {
linkId: 'link-skf-001',
constructeurId: mockConstructeurSKF.id,
constructeur: mockConstructeurSKF,
supplierReference: 'SKF-6205-2RS',
}
export const mockLinkFAG: ConstructeurLinkEntry = {
linkId: 'link-fag-002',
constructeurId: mockConstructeurFAG.id,
constructeur: mockConstructeurFAG,
supplierReference: 'FAG-6205-C-2HRS',
}
// ---------------------------------------------------------------------------
// Custom field definitions (6 types)
// ---------------------------------------------------------------------------
export const mockCustomFieldDefs: CustomFieldDefinition[] = [
{
id: 'cf-def-001',
name: 'Tension nominale',
type: 'number',
required: true,
options: [],
defaultValue: '220',
orderIndex: 0,
machineContextOnly: false,
},
{
id: 'cf-def-002',
name: 'Certifié CE',
type: 'boolean',
required: false,
options: [],
defaultValue: 'false',
orderIndex: 1,
machineContextOnly: false,
},
{
id: 'cf-def-003',
name: 'Indice de protection',
type: 'select',
required: false,
options: ['IP54', 'IP55', 'IP65'],
defaultValue: null,
orderIndex: 2,
machineContextOnly: false,
},
{
id: 'cf-def-004',
name: 'Date de calibration',
type: 'date',
required: false,
options: [],
defaultValue: null,
orderIndex: 3,
machineContextOnly: false,
},
{
id: 'cf-def-005',
name: 'Remarques techniques',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 4,
machineContextOnly: false,
},
{
id: 'cf-def-006',
name: 'Position sur machine',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 5,
machineContextOnly: true,
},
]
// ---------------------------------------------------------------------------
// Custom field values (matching first 5 defs)
// ---------------------------------------------------------------------------
export const mockCustomFieldValues: CustomFieldValue[] = [
{
id: 'cfv-001',
value: '220',
customField: mockCustomFieldDefs[0]!,
},
{
id: 'cfv-002',
value: 'true',
customField: mockCustomFieldDefs[1]!,
},
{
id: 'cfv-003',
value: 'IP65',
customField: mockCustomFieldDefs[2]!,
},
{
id: 'cfv-004',
value: '2025-06-15',
customField: mockCustomFieldDefs[3]!,
},
{
id: 'cfv-005',
value: 'Roulement renforcé pour environnement humide',
customField: mockCustomFieldDefs[4]!,
},
]
// ---------------------------------------------------------------------------
// Component ModelType structure
// ---------------------------------------------------------------------------
export const mockComponentStructure: ComponentModelStructure = {
customFields: [
{ name: 'Tension nominale', type: 'number', required: true, defaultValue: '220', orderIndex: 0 },
{ name: 'Certifié CE', type: 'boolean', required: false, defaultValue: 'false', orderIndex: 1 },
{ name: 'Indice de protection', type: 'select', required: false, options: ['IP54', 'IP55', 'IP65'], orderIndex: 2 },
],
pieces: [
{
typePieceId: 'tp-bearing-001',
typePieceLabel: 'Roulement',
reference: 'REF-PIECE-001',
familyCode: 'ROUL',
role: 'support',
quantity: 2,
},
{
typePieceId: 'tp-seal-002',
typePieceLabel: 'Joint',
reference: 'REF-PIECE-002',
familyCode: 'JOINT',
role: 'étanchéité',
quantity: 1,
},
],
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
reference: 'REF-PROD-001',
familyCode: 'LUB',
role: 'lubrification',
},
],
subcomponents: [
{
typeComposantId: 'tc-sub-001',
typeComposantLabel: 'Sous-ensemble palier',
familyCode: 'PAL',
alias: 'Palier avant',
subcomponents: [],
},
],
}
// ---------------------------------------------------------------------------
// Full API response — Composant
// ---------------------------------------------------------------------------
export const mockComponentFromApi = {
'@id': '/api/composants/comp-001',
'@type': 'Composant',
id: 'comp-001',
name: 'Moteur principal',
reference: 'COMP-MOT-001',
typeComposant: { id: 'tc-moteur', name: 'Moteur électrique', code: 'MOT' },
site: { id: 'site-001', name: 'Usine Nord' },
pieceSlots: [
{
id: 'ps-001',
piece: { id: 'piece-001', name: 'Roulement 6205', reference: 'ROUL-6205' },
typePiece: { id: 'tp-bearing-001', name: 'Roulement' },
role: 'support',
quantity: 2,
},
{
id: 'ps-002',
piece: { id: 'piece-002', name: 'Joint torique', reference: 'JOINT-001' },
typePiece: { id: 'tp-seal-002', name: 'Joint' },
role: 'étanchéité',
quantity: 1,
},
],
productSlots: [
{
id: 'prs-001',
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
role: 'lubrification',
},
],
subcomponentSlots: [
{
id: 'scs-001',
subcomponent: { id: 'comp-sub-001', name: 'Palier avant', reference: 'PAL-AV-001' },
typeComposant: { id: 'tc-sub-001', name: 'Sous-ensemble palier' },
alias: 'Palier avant',
},
],
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
],
customFieldValues: mockCustomFieldValues.map(cfv => ({
id: cfv.id,
value: cfv.value,
customField: {
id: cfv.customField.id,
name: cfv.customField.name,
type: cfv.customField.type,
required: cfv.customField.required,
options: cfv.customField.options,
defaultValue: cfv.customField.defaultValue,
orderIndex: cfv.customField.orderIndex,
machineContextOnly: cfv.customField.machineContextOnly,
},
})),
createdAt: '2025-01-15T10:00:00+00:00',
updatedAt: '2025-03-20T14:30:00+00:00',
}
// ---------------------------------------------------------------------------
// Full API response — Piece
// ---------------------------------------------------------------------------
export const mockPieceFromApi = {
'@id': '/api/pieces/piece-001',
'@type': 'Piece',
id: 'piece-001',
name: 'Roulement 6205',
reference: 'ROUL-6205',
typePiece: { id: 'tp-bearing-001', name: 'Roulement', code: 'ROUL' },
site: { id: 'site-001', name: 'Usine Nord' },
productSlots: [
{
id: 'pps-001',
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
role: 'lubrification',
},
],
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
{
id: mockLinkFAG.linkId,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
},
],
customFieldValues: [
{
id: 'cfv-piece-001',
value: '6205',
customField: {
id: 'cf-piece-def-001',
name: 'Référence interne',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
},
],
createdAt: '2025-01-10T08:00:00+00:00',
updatedAt: '2025-03-18T11:00:00+00:00',
}
// ---------------------------------------------------------------------------
// Full API response — Product
// ---------------------------------------------------------------------------
export const mockProductFromApi = {
'@id': '/api/products/prod-001',
'@type': 'Product',
id: 'prod-001',
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF', code: 'LUB' },
site: { id: 'site-001', name: 'Usine Nord' },
supplierPrice: 45.90,
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: 'LGMT2/1',
},
],
createdAt: '2025-02-01T09:00:00+00:00',
updatedAt: '2025-03-10T16:00:00+00:00',
}
// ---------------------------------------------------------------------------
// JSON-LD collection wrapper
// ---------------------------------------------------------------------------
export function wrapCollection<T>(items: T[], total?: number) {
return {
'@context': '/api/contexts/Collection',
'@id': '/api/collection',
'@type': 'Collection',
'totalItems': total ?? items.length,
'member': items,
}
}
// ---------------------------------------------------------------------------
// Machine custom field definitions (5 types)
// ---------------------------------------------------------------------------
export const mockMachineCustomFieldDefs: CustomFieldDefinition[] = [
{
id: 'mcf-def-001',
name: 'Numéro de série',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
{
id: 'mcf-def-002',
name: 'En service',
type: 'boolean',
required: false,
options: [],
defaultValue: 'false',
orderIndex: 1,
machineContextOnly: false,
},
{
id: 'mcf-def-003',
name: 'Puissance (kW)',
type: 'number',
required: false,
options: [],
defaultValue: null,
orderIndex: 2,
machineContextOnly: false,
},
{
id: 'mcf-def-004',
name: 'Catégorie ATEX',
type: 'select',
required: false,
options: ['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'],
defaultValue: null,
orderIndex: 3,
machineContextOnly: false,
},
{
id: 'mcf-def-005',
name: 'Date mise en service',
type: 'date',
required: false,
options: [],
defaultValue: null,
orderIndex: 4,
machineContextOnly: false,
},
]
// ---------------------------------------------------------------------------
// Machine custom field values (matching defs, includes number '0' and boolean 'true')
// ---------------------------------------------------------------------------
export const mockMachineCustomFieldValues: CustomFieldValue[] = [
{
id: 'mcfv-001',
value: 'SN-2025-001234',
customField: mockMachineCustomFieldDefs[0]!,
},
{
id: 'mcfv-002',
value: 'true',
customField: mockMachineCustomFieldDefs[1]!,
},
{
id: 'mcfv-003',
value: '0',
customField: mockMachineCustomFieldDefs[2]!,
},
{
id: 'mcfv-004',
value: 'Zone 1',
customField: mockMachineCustomFieldDefs[3]!,
},
{
id: 'mcfv-005',
value: '2025-01-15',
customField: mockMachineCustomFieldDefs[4]!,
},
]