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 autres specs commercial. 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('useSuppliersRepository', () => ({ items: ref([ { id: 7, companyName: 'ACME', categories: [{ code: 'NEG', name: 'Négociant' }], sites: [{ id: 86, name: '86', color: '#123456' }], updatedAt: '2026-01-15T10:00:00+00:00', }, ]), 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 SuppliersIndex = (await import('../suppliers/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(SuppliersIndex, { global: { stubs: { PageHeader: PageHeaderStub, MalioButton: ButtonStub, MalioDataTable: DataTableStub, MalioDrawer: DrawerStub, MalioAccordion: SlotStub, MalioAccordionItem: SlotStub, MalioInputText: InputTextStub, MalioCheckbox: CheckboxStub, }, }, }) } describe('Répertoire fournisseurs (page /suppliers)', () => { 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 === 'commercial.suppliers.manage') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true) }) it('masque « + Ajouter » sans la permission manage (view seul)', async () => { mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="commercial.suppliers.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('/suppliers/7') }) it('charge les categories de type FOURNISSEUR pour le filtre', async () => { mountPage() await flushPromises() expect(mockApiGet).toHaveBeenCalledWith( '/categories', expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }), expect.objectContaining({ toast: false }), ) }) it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click') await flushPromises() expect(mockApiGet).toHaveBeenCalledWith( '/suppliers/export.xlsx', expect.any(Object), expect.objectContaining({ responseType: 'blob', toast: false }), ) }) it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { const wrapper = mountPage() await flushPromises() // Coche « Inclure les archivés » puis applique les filtres. await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith( { includeArchived: true }, { replace: true }, ) // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. expect(mockPush).not.toHaveBeenCalled() }) it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true) // Réinitialiser → query propre (setFilters avec objet vide). await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) }) })