import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { defineComponent, h, ref } from 'vue' // ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── // La page ne les importe pas (auto-import) : on les expose en globals pour le // runtime de test (happy-dom). Meme philosophie que les specs M1/M2/M3. const mockPush = vi.hoisted(() => vi.fn()) const mockApiGet = vi.hoisted(() => vi.fn()) const mockCan = vi.hoisted(() => vi.fn()) const mockSetFilters = vi.hoisted(() => vi.fn()) const mockFetch = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn()) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('useHead', () => undefined) vi.stubGlobal('useApi', () => ({ get: mockApiGet })) vi.stubGlobal('useRouter', () => ({ push: mockPush })) vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) vi.stubGlobal('usePermissions', () => ({ can: mockCan })) // Le repository est lui aussi un auto-import : on controle items + setFilters. vi.stubGlobal('useCarriersRepository', () => ({ items: ref([ { id: 7, name: 'TRANSPORTS ACME', certificationType: 'QUALIMAT', qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' }, updatedAt: '2026-01-15T10:00:00+00:00', isArchived: false, }, ]), totalItems: ref(1), currentPage: ref(1), itemsPerPage: ref(10), itemsPerPageOptions: ref([10, 25, 50]), fetch: mockFetch, goToPage: vi.fn(), setItemsPerPage: vi.fn(), setFilters: mockSetFilters, })) // happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques // sur la classe URL existante (sans la remplacer — sinon `new URL()` casse). globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') globalThis.URL.revokeObjectURL = vi.fn() // Import APRES les stubs (la page resout les auto-imports au top-level du module). const CarriersIndex = (await import('../carriers/index.vue')).default // ── Stubs de composants ────────────────────────────────────────────────────── const ButtonStub = defineComponent({ props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, emits: ['click'], setup(props, { emit }) { return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) }, }) const DataTableStub = defineComponent({ props: { items: { type: Array, default: () => [] } }, emits: ['row-click', 'update:page', 'update:per-page'], setup(props, { emit }) { return () => h('div', { 'data-testid': 'datatable' }, (props.items as Array<{ id: number }>).map(it => h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }), ), ) }, }) const DrawerStub = defineComponent({ props: { modelValue: { type: Boolean, default: false } }, setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) }, }) const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } }) const PageHeaderStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, }) const CheckboxStub = defineComponent({ props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } }, emits: ['update:model-value'], setup(props, { emit }) { return () => h('input', { 'type': 'checkbox', 'data-id': props.id, 'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked), }) }, }) const InputTextStub = defineComponent({ setup() { return () => h('input') } }) function mountPage() { return mount(CarriersIndex, { global: { stubs: { PageHeader: PageHeaderStub, MalioButton: ButtonStub, MalioDataTable: DataTableStub, MalioDrawer: DrawerStub, MalioAccordion: SlotStub, MalioAccordionItem: SlotStub, MalioInputText: InputTextStub, MalioCheckbox: CheckboxStub, }, }, }) } describe('Répertoire transporteurs (page /carriers)', () => { beforeEach(() => { mockPush.mockReset() mockApiGet.mockReset().mockResolvedValue({ member: [] }) mockCan.mockReset().mockReturnValue(true) mockSetFilters.mockReset() mockFetch.mockReset() mockToastError.mockReset() }) it('charge la liste au montage', async () => { mountPage() await flushPromises() expect(mockFetch).toHaveBeenCalled() }) it('affiche « + Ajouter » uniquement avec la permission manage', async () => { mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.manage') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true) }) it('masque « + Ajouter » sans la permission manage (view seul)', async () => { mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(false) }) it('navigue vers la consultation au clic sur une ligne', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('tr[data-row-id="7"]').trigger('click') expect(mockPush).toHaveBeenCalledWith('/carriers/7') }) it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('[data-label="transport.carriers.export"]').trigger('click') await flushPromises() expect(mockApiGet).toHaveBeenCalledWith( '/carriers/export.xlsx', expect.any(Object), expect.objectContaining({ responseType: 'blob', toast: false }), ) }) it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => { const wrapper = mountPage() await flushPromises() // Coche « Voir les archivés » puis applique les filtres. await wrapper.find('input[data-id="filter-archived-only"]').setValue(true) await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith( { archivedOnly: true }, { replace: true }, ) // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. expect(mockPush).not.toHaveBeenCalled() }) it('repercute les certifications cochees dans setFilters (filtre multi)', async () => { const wrapper = mountPage() await flushPromises() // Coche deux certifications via les cases a cocher (pattern repertoire clients). await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true) await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true) await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith( { 'certificationType[]': ['QUALIMAT', 'AUTRE'] }, { replace: true }, ) }) it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('input[data-id="filter-archived-only"]').setValue(true) await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true) // Réinitialiser → query propre (setFilters avec objet vide). await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) }) })