From 2c2de8bc00dcc3f78e24fb7963f587e4c170264a Mon Sep 17 00:00:00 2001 From: r-dev Date: Mon, 6 Apr 2026 15:59:55 +0200 Subject: [PATCH] test(machine-detail) : add hierarchy loading and override data integrity tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composables/useMachineDetailData.test.ts | 700 ++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 frontend/tests/composables/useMachineDetailData.test.ts diff --git a/frontend/tests/composables/useMachineDetailData.test.ts b/frontend/tests/composables/useMachineDetailData.test.ts new file mode 100644 index 0000000..c2f3ad2 --- /dev/null +++ b/frontend/tests/composables/useMachineDetailData.test.ts @@ -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(''), +})) + +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[] + 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[] + 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[] + 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[])[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[])[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[])[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[])[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[] + 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[] + 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[])[0]! + const ctxValues = piece.contextCustomFieldValues as Record[] + 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[])[0]! + const ctxValues = piece.contextCustomFieldValues as Record[] + 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 + 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) + }) +})