- 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>
270 lines
8.0 KiB
TypeScript
270 lines
8.0 KiB
TypeScript
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()
|
|
})
|
|
})
|