diff --git a/frontend/tests/composables/useComposants.test.ts b/frontend/tests/composables/useComposants.test.ts new file mode 100644 index 0000000..86c3955 --- /dev/null +++ b/frontend/tests/composables/useComposants.test.ts @@ -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') + }) +}) diff --git a/frontend/tests/composables/usePieces.test.ts b/frontend/tests/composables/usePieces.test.ts new file mode 100644 index 0000000..d652c92 --- /dev/null +++ b/frontend/tests/composables/usePieces.test.ts @@ -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) + }) +}) diff --git a/frontend/tests/composables/useProducts.test.ts b/frontend/tests/composables/useProducts.test.ts new file mode 100644 index 0000000..e4888d0 --- /dev/null +++ b/frontend/tests/composables/useProducts.test.ts @@ -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') + }) +})