import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { computed, defineComponent, h, ref, watchEffect } from 'vue' import type { Site } from '~/shared/types/sites' import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite' import SiteSelector from '../SiteSelector.vue' const mockPatch = vi.hoisted(() => vi.fn()) const mockAuthUser = vi.hoisted(() => ({ value: null as { sites: Site[]; currentSite: Site | null } | null, })) // Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite, // useAuthStore, useI18n, watchEffect, computed sans import explicite // (pattern Nuxt). En Vitest on les expose comme globals. vi.stubGlobal('useCurrentSite', useCurrentSite) vi.stubGlobal('useApi', () => ({ patch: mockPatch })) vi.stubGlobal('useAuthStore', () => ({ get user() { return mockAuthUser.value }, setCurrentSite(site: Site | null) { if (mockAuthUser.value) { mockAuthUser.value.currentSite = site } }, })) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('watchEffect', watchEffect) vi.stubGlobal('computed', computed) vi.stubGlobal('ref', ref) // useSidebar et refreshNuxtData sont consommes par useCurrentSite apres // un switch reussi — stubs minimaux pour eviter ReferenceError au mount. vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() })) vi.stubGlobal('refreshNuxtData', vi.fn()) // Stub de MalioSiteSelector : on se contente de tracker les props recues // et de re-emettre `change` quand on le simule via `trigger`. Evite de // monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge). const MalioSiteSelectorStub = defineComponent({ name: 'MalioSiteSelector', props: { sites: { type: Array, required: true }, modelValue: { type: String, default: undefined }, groupClass: { type: String, default: '' }, tileClass: { type: String, default: '' }, labelClass: { type: String, default: '' }, }, emits: ['update:modelValue', 'change'], setup(props, { emit }) { return () => h('div', { 'data-testid': 'malio-site-selector', 'data-sites-count': String((props.sites as unknown[]).length), 'data-active-id': String(props.modelValue ?? ''), 'data-label-class': props.labelClass, }, [ ...(props.sites as Array<{ id: string; name: string; color: string }>).map(site => h('button', { 'data-testid': `tile-${site.id}`, // Emet les deux events comme le vrai MalioSiteSelector // (update:modelValue + change). Le wrapper n'ecoute que // change aujourd'hui, mais tracker les deux grave la // signature et prepare un eventuel v-model futur. onClick: () => { emit('update:modelValue', site.id) emit('change', site) }, }, site.name), ), ]) }, }) const SITE_A: Site = { id: 1, name: 'Chatellerault', street: '14 All.', complement: null, postalCode: '86100', city: 'Châtellerault', color: '#056CF2', fullAddress: '14 All.\n86100 Châtellerault', } const SITE_B: Site = { id: 2, name: 'Saint-Jean', street: 'Z i', complement: null, postalCode: '17400', city: 'Fontenet', color: '#F3CB00', fullAddress: 'Z i\n17400 Fontenet', } function mountSelector() { return mount(SiteSelector, { global: { stubs: { MalioSiteSelector: MalioSiteSelectorStub }, }, }) } describe('SiteSelector', () => { beforeEach(() => { mockPatch.mockReset() mockAuthUser.value = { sites: [SITE_A, SITE_B], currentSite: SITE_A, } }) it('rend un tile par site autorise', () => { const wrapper = mountSelector() const stub = wrapper.find('[data-testid="malio-site-selector"]') expect(stub.attributes('data-sites-count')).toBe('2') }) it('marque le site courant via modelValue (string)', () => { const wrapper = mountSelector() const stub = wrapper.find('[data-testid="malio-site-selector"]') // Chatellerault id=1 => '1' expect(stub.attributes('data-active-id')).toBe('1') }) it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => { // Decision design : texte blanc par defaut Malio mais taille 24px // imposee par la maquette. Le reste des attributs text (white, bold, // uppercase, tracking-wide) provient du default Malio via twMerge. const wrapper = mountSelector() const stub = wrapper.find('[data-testid="malio-site-selector"]') expect(stub.attributes('data-label-class')).toBe('text-2xl') }) it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => { mockPatch.mockResolvedValueOnce({}) const wrapper = mountSelector() await wrapper.find('[data-testid="tile-2"]').trigger('click') await flushPromises() expect(mockPatch).toHaveBeenCalledWith( '/me/current-site', { site: '/api/sites/2' }, expect.anything(), ) }) it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => { // Le court-circuit "si deja actif, ne rien faire" a ete supprime // pour couvrir le cas ou un autre onglet a modifie le site courant // cote serveur : un clic sur la tile localement "active" (etat // potentiellement stale) force une resync via PATCH. Le prix est un // PATCH superflu quand l'etat local est effectivement a jour. const wrapper = mountSelector() await wrapper.find('[data-testid="tile-1"]').trigger('click') await flushPromises() expect(mockPatch).toHaveBeenCalledWith( '/me/current-site', { site: '/api/sites/1' }, expect.anything(), ) }) it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => { // Scenario : admin clique sur Saint-Jean alors que Chatellerault est // actif, mais le serveur rejette (ex : 500). Apres rollback dans // useCurrentSite, le composant doit re-afficher Chatellerault actif. mockPatch.mockRejectedValueOnce(new Error('server down')) const wrapper = mountSelector() // Avant : Chatellerault (id=1) actif. expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id')) .toBe('1') await wrapper.find('[data-testid="tile-2"]').trigger('click') await flushPromises() // Apres rollback : Chatellerault (id=1) de nouveau actif. expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id')) .toBe('1') // Le store auth ne doit PAS avoir ete laisse avec SITE_B. expect(mockAuthUser.value?.currentSite).toEqual(SITE_A) }) })