Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962576eec | ||
| 7d98c1598c | |||
|
|
4772f057a3 | ||
| 6680423e64 | |||
| 2c2de8bc00 | |||
| 150aceac24 | |||
| 972f30e772 | |||
| 8af68c9628 | |||
| eb68336723 | |||
| eeba229574 | |||
| 4454bbea3d | |||
| 1e40334e11 | |||
| 83c75ecf69 | |||
| b54739f6de | |||
| 82cbeb91a5 | |||
| e70c66e215 |
@@ -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
175
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.18'
|
||||
app.version: '1.9.20'
|
||||
|
||||
737
frontend/tests/composables/useComponentCreate.test.ts
Normal file
737
frontend/tests/composables/useComponentCreate.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
890
frontend/tests/composables/useComponentEdit.test.ts
Normal file
890
frontend/tests/composables/useComponentEdit.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
157
frontend/tests/composables/useComposants.test.ts
Normal file
157
frontend/tests/composables/useComposants.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal file
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
319
frontend/tests/composables/useDocuments.test.ts
Normal file
319
frontend/tests/composables/useDocuments.test.ts
Normal 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é')
|
||||
})
|
||||
})
|
||||
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal file
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal file
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
644
frontend/tests/composables/usePieceEdit.test.ts
Normal file
644
frontend/tests/composables/usePieceEdit.test.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
166
frontend/tests/composables/usePieces.test.ts
Normal file
166
frontend/tests/composables/usePieces.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
209
frontend/tests/composables/useProducts.test.ts
Normal file
209
frontend/tests/composables/useProducts.test.ts
Normal 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
438
frontend/tests/fixtures/mockData.ts
vendored
Normal 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]!,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user