feat: add API optimizations, cache invalidation and comprehensive test suite

- Add abort controllers and request deduplication to composables
- Add entity type cache invalidation on create/update/delete flows
- Add 179 new tests (utilities, services, composables, components)
- Fix Vue runtime warnings in structure editors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-09 14:19:08 +01:00
parent 634184c2be
commit 67af3c9c46
28 changed files with 2287 additions and 42 deletions

View File

@@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockShowInfo = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
apiCall: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showInfo: mockShowInfo,
showSuccess: vi.fn(),
showError: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const GUARD_CONFIG = {
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe('initial state', () => {
it('has linkedCount 0 and restrictedMode false', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.linkedLoading.value).toBe(false)
})
it('has empty messages when no linked items', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.restrictedModeMessage.value).toBe('')
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// loadLinkedCount
// ---------------------------------------------------------------------------
describe('loadLinkedCount', () => {
it('sets linkedCount from API totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(5)
expect(guard.isRestrictedMode.value).toBe(true)
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining('/composants?'),
)
})
it('sets linkedCount 0 when API returns 0 items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('extracts totalItems from hydra:totalItems format', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(3)
})
it('falls back to member.length when no totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { member: [{}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(2)
})
it('falls back to hydra:member.length', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:member': [{}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(1)
})
it('sets linkedCount 0 on API failure', async () => {
mockGet.mockResolvedValue({ success: false })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('sets linkedCount 0 on exception', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
})
it('sends correct filter parameters', async () => {
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('abc-123')
const callUrl = mockGet.mock.calls[0][0] as string
expect(callUrl).toContain('itemsPerPage=1')
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
})
})
// ---------------------------------------------------------------------------
// restrictedModeMessage
// ---------------------------------------------------------------------------
describe('restrictedModeMessage', () => {
it('shows singular message for 1 linked item', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 1 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('1 composant')
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
})
it('shows plural message for multiple linked items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('5 composants')
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
})
it('uses custom labels from config', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 3 },
})
const guard = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
})
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
})
})
// ---------------------------------------------------------------------------
// isSubmitBlocked & submitBlockMessage
// ---------------------------------------------------------------------------
describe('submit blocking', () => {
it('blocks submit during loading', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading state by starting a load without awaiting
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
guard.loadLinkedCount('mt-1')
expect(guard.linkedLoading.value).toBe(true)
expect(guard.isSubmitBlocked.value).toBe(true)
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
})
it('unblocks submit after loading completes', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// guardSubmitOrNotify
// ---------------------------------------------------------------------------
describe('guardSubmitOrNotify', () => {
it('returns false when not blocked', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(false)
expect(mockShowInfo).not.toHaveBeenCalled()
})
it('returns true and shows info when blocked', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading
mockGet.mockReturnValue(new Promise(() => {}))
guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(true)
expect(mockShowInfo).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,412 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityTypes, invalidateEntityTypeCache } from '~/composables/useEntityTypes'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const mockListModelTypes = vi.fn()
const mockCreateModelType = vi.fn()
const mockUpdateModelType = vi.fn()
const mockDeleteModelType = vi.fn()
vi.mock('~/services/modelTypes', () => ({
listModelTypes: (...args: any[]) => mockListModelTypes(...args),
createModelType: (...args: any[]) => mockCreateModelType(...args),
updateModelType: (...args: any[]) => mockUpdateModelType(...args),
deleteModelType: (...args: any[]) => mockDeleteModelType(...args),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fakeItem = (id: string, name: string) => ({
id,
name,
code: name.toLowerCase(),
category: 'COMPONENT' as const,
structure: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
notes: null,
description: null,
})
function resetState() {
// Reset singleton state via public APIs
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
comp.types.value = []
invalidateEntityTypeCache('COMPONENT')
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
piece.types.value = []
invalidateEntityTypeCache('PIECE')
const product = useEntityTypes({ category: 'PRODUCT', label: 'produit' })
product.types.value = []
invalidateEntityTypeCache('PRODUCT')
}
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
// ---------------------------------------------------------------------------
// loadTypes
// ---------------------------------------------------------------------------
describe('loadTypes', () => {
it('fetches from API and stores in types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Type A'), fakeItem('2', 'Type B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(2)
expect(types.value[0].name).toBe('Type A')
expect(mockListModelTypes).toHaveBeenCalledOnce()
expect(mockListModelTypes).toHaveBeenCalledWith({
category: 'COMPONENT',
sort: 'name',
dir: 'asc',
limit: 200,
})
})
it('returns cached data on second call without force', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Second call — should return cache
const result = await loadTypes()
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(mockListModelTypes).toHaveBeenCalledTimes(1) // NOT called again
})
it('refetches with force: true', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('1', 'Old')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('1', 'New')], total: 1, offset: 0, limit: 200 })
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].name).toBe('Old')
await loadTypes({ force: true })
expect(types.value[0].name).toBe('New')
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
})
it('sets loading during fetch', async () => {
let resolvePromise: (value: any) => void
mockListModelTypes.mockReturnValue(new Promise((resolve) => { resolvePromise = resolve }))
const { loadTypes, loading } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(loading.value).toBe(false)
const promise = loadTypes()
expect(loading.value).toBe(true)
resolvePromise!({ items: [], total: 0, offset: 0, limit: 200 })
await promise
expect(loading.value).toBe(false)
})
it('shows error on API failure', async () => {
mockListModelTypes.mockRejectedValue(new Error('Network error'))
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await loadTypes()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalledWith(
expect.stringContaining('Network error'),
)
})
it('normalizes items with description fallback', async () => {
mockListModelTypes.mockResolvedValue({
items: [{ ...fakeItem('1', 'Test'), notes: 'From notes', description: null }],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value[0].description).toBe('From notes')
})
})
// ---------------------------------------------------------------------------
// createType
// ---------------------------------------------------------------------------
describe('createType', () => {
it('creates and pushes to types array', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Created'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Created' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Created')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Created'))
})
it('sends correct payload with auto-generated code', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Ma Catégorie'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Ma Catégorie' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Ma Catégorie',
code: 'ma-categorie',
category: 'COMPONENT',
}),
)
})
it('uses provided code if available', async () => {
mockCreateModelType.mockResolvedValue(fakeItem('new-1', 'Test'))
const { createType } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await createType({ name: 'Test', code: 'custom-code' })
expect(mockCreateModelType).toHaveBeenCalledWith(
expect.objectContaining({ code: 'custom-code' }),
)
})
it('shows error on API failure without modifying types', async () => {
mockCreateModelType.mockRejectedValue(new Error('Create failed'))
const { createType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const result = await createType({ name: 'Fail' })
expect(result.success).toBe(false)
expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// updateType
// ---------------------------------------------------------------------------
describe('updateType', () => {
it('updates the existing item in types array', async () => {
// Pre-populate
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockResolvedValue(fakeItem('1', 'Updated'))
const result = await updateType('1', { name: 'Updated' })
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].name).toBe('Updated')
expect(mockShowSuccess).toHaveBeenCalledWith(expect.stringContaining('Updated'))
})
it('shows error on API failure without modifying types', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Original')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, updateType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockUpdateModelType.mockRejectedValue(new Error('Update failed'))
const result = await updateType('1', { name: 'Bad' })
expect(result.success).toBe(false)
expect(types.value[0].name).toBe('Original')
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// deleteType
// ---------------------------------------------------------------------------
describe('deleteType', () => {
it('removes item from types array', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'A'), fakeItem('2', 'B')],
total: 2,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(types.value).toHaveLength(2)
mockDeleteModelType.mockResolvedValue(undefined)
const result = await deleteType('1')
expect(result.success).toBe(true)
expect(types.value).toHaveLength(1)
expect(types.value[0].id).toBe('2')
expect(mockShowSuccess).toHaveBeenCalled()
})
it('shows error on API failure without removing item', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Keep')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes, deleteType, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
mockDeleteModelType.mockRejectedValue(new Error('Delete failed'))
const result = await deleteType('1')
expect(result.success).toBe(false)
expect(types.value).toHaveLength(1)
expect(mockShowError).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// invalidateEntityTypeCache
// ---------------------------------------------------------------------------
describe('invalidateEntityTypeCache', () => {
it('forces next loadTypes to refetch', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Cached')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(1)
// Without invalidation, wouldn't refetch
invalidateEntityTypeCache('COMPONENT')
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Fresh')],
total: 1,
offset: 0,
limit: 200,
})
const { loadTypes: loadAgain, types } = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await loadAgain()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
expect(types.value[0].name).toBe('Fresh')
})
it('does not crash for unknown category', () => {
expect(() => invalidateEntityTypeCache('COMPONENT')).not.toThrow()
})
})
// ---------------------------------------------------------------------------
// Singleton per category
// ---------------------------------------------------------------------------
describe('singleton per category', () => {
it('shares state between same category calls', async () => {
mockListModelTypes.mockResolvedValue({
items: [fakeItem('1', 'Shared')],
total: 1,
offset: 0,
limit: 200,
})
const a = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
await a.loadTypes()
const b = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
expect(b.types.value).toHaveLength(1)
expect(b.types.value[0].name).toBe('Shared')
})
it('isolates state between different categories', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Component')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(comp.types.value).toHaveLength(1)
expect(comp.types.value[0].name).toBe('Component')
expect(piece.types.value).toHaveLength(1)
expect(piece.types.value[0].name).toBe('Piece')
})
it('invalidateEntityTypeCache only affects target category', async () => {
mockListModelTypes
.mockResolvedValueOnce({ items: [fakeItem('c1', 'Comp')], total: 1, offset: 0, limit: 200 })
.mockResolvedValueOnce({ items: [fakeItem('p1', 'Piece')], total: 1, offset: 0, limit: 200 })
const comp = useEntityTypes({ category: 'COMPONENT', label: 'composant' })
const piece = useEntityTypes({ category: 'PIECE', label: 'pièce' })
await comp.loadTypes()
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2)
// Invalidate only COMPONENT
invalidateEntityTypeCache('COMPONENT')
// PIECE should still use cache
await piece.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(2) // No extra call
// COMPONENT should refetch
mockListModelTypes.mockResolvedValueOnce({ items: [fakeItem('c1', 'Refreshed')], total: 1, offset: 0, limit: 200 })
await comp.loadTypes()
expect(mockListModelTypes).toHaveBeenCalledTimes(3)
})
})