import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Site } from '~/shared/types/sites' import { useCurrentSite } from '../useCurrentSite' const mockPatch = vi.hoisted(() => vi.fn()) const mockAuthUser = vi.hoisted(() => ({ value: null as { sites: Site[]; currentSite: Site | null } | null, })) // Stub des auto-imports Nuxt consommes par le composable. vi.stubGlobal('useApi', () => ({ patch: mockPatch })) vi.stubGlobal('useAuthStore', () => ({ get user() { return mockAuthUser.value }, // Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute // user.currentSite si user present, no-op sinon. setCurrentSite(site: Site | null) { if (mockAuthUser.value) { mockAuthUser.value.currentSite = site } }, })) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key, })) // useSidebar est consomme par useCurrentSite pour rafraichir la sidebar // apres un switch reussi. Stub minimal retournant un loadSidebar no-op. vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn(), })) // refreshNuxtData est appele apres un switch pour invalider les donnees // de page precedemment fetchees. Stub no-op pour les tests unitaires. vi.stubGlobal('refreshNuxtData', vi.fn()) const SITE_A: Site = { id: 1, name: 'Chatellerault', street: '14 All. d\'Argenson', complement: null, postalCode: '86100', city: 'Châtellerault', color: '#056CF2', fullAddress: '14 All. d\'Argenson\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', } describe('useCurrentSite', () => { beforeEach(() => { mockPatch.mockReset() mockAuthUser.value = { sites: [SITE_A, SITE_B], currentSite: SITE_A, } const { resetCurrentSite } = useCurrentSite() resetCurrentSite() }) it('syncFromAuth hydrate le state depuis le store auth', () => { const { syncFromAuth, currentSite, availableSites } = useCurrentSite() syncFromAuth() expect(currentSite.value).toEqual(SITE_A) expect(availableSites.value).toEqual([SITE_A, SITE_B]) }) it('syncFromAuth gere le cas user null (deconnecte)', () => { mockAuthUser.value = null const { syncFromAuth, currentSite, availableSites } = useCurrentSite() syncFromAuth() expect(currentSite.value).toBeNull() expect(availableSites.value).toEqual([]) }) it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => { mockPatch.mockImplementation(async () => { // Au moment du resolve, currentSite est deja basculé. const state = useCurrentSite() expect(state.currentSite.value).toEqual(SITE_B) return {} }) const { syncFromAuth, switchSite, currentSite } = useCurrentSite() syncFromAuth() await switchSite(SITE_B) expect(currentSite.value).toEqual(SITE_B) expect(mockPatch).toHaveBeenCalledWith( '/me/current-site', { site: '/api/sites/2' }, expect.objectContaining({ toastSuccessMessage: expect.any(String) }), ) }) it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => { mockPatch.mockResolvedValueOnce({}) const { syncFromAuth, switchSite } = useCurrentSite() syncFromAuth() await switchSite(SITE_B) expect(mockAuthUser.value?.currentSite).toEqual(SITE_B) }) it('switchSite rollback le currentSite local si la requete echoue', async () => { mockPatch.mockRejectedValueOnce(new Error('network')) const { syncFromAuth, switchSite, currentSite } = useCurrentSite() syncFromAuth() await expect(switchSite(SITE_B)).rejects.toThrow('network') expect(currentSite.value).toEqual(SITE_A) }) it('switchSite ne propage pas au store auth en cas d\'echec', async () => { mockPatch.mockRejectedValueOnce(new Error('network')) const { syncFromAuth, switchSite } = useCurrentSite() syncFromAuth() await expect(switchSite(SITE_B)).rejects.toThrow() expect(mockAuthUser.value?.currentSite).toEqual(SITE_A) }) it('switching est vrai pendant la requete et faux apres', async () => { let resolveRequest: (value: unknown) => void = () => {} mockPatch.mockImplementation( () => new Promise((resolve) => { resolveRequest = resolve }), ) const { syncFromAuth, switchSite, switching } = useCurrentSite() syncFromAuth() const pending = switchSite(SITE_B) expect(switching.value).toBe(true) resolveRequest({}) await pending expect(switching.value).toBe(false) }) it('double switchSite concurrent : le second appel est un no-op silencieux', async () => { let resolveRequest: (value: unknown) => void = () => {} mockPatch.mockImplementation( () => new Promise((resolve) => { resolveRequest = resolve }), ) const { syncFromAuth, switchSite } = useCurrentSite() syncFromAuth() const first = switchSite(SITE_B) await switchSite(SITE_A) // doit etre no-op (switching=true) // Le second appel ne declenche pas de PATCH additionnel. expect(mockPatch).toHaveBeenCalledTimes(1) resolveRequest({}) await first }) it('resetCurrentSite vide tout l\'etat singleton', () => { const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite() syncFromAuth() expect(currentSite.value).not.toBeNull() resetCurrentSite() expect(currentSite.value).toBeNull() expect(availableSites.value).toEqual([]) expect(switching.value).toBe(false) }) it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => { // Historique : une premiere version du composable appelait useI18n() // dans `switchSite` plutot qu'au top du setup. Consequence en runtime : // l'appel depuis un event handler (click) hors contexte setup levait // "Must be called at the top of a setup function". Ce test grave le // contrat : useCurrentSite() DOIT capturer les 3 services a // l'initialisation, pas paresseusement. // // Verification : on remplace useI18n par un mock qui throw au 2e appel. // Si switchSite invoque useI18n() lui-meme, ce test cassera. let i18nCallCount = 0 vi.stubGlobal('useI18n', () => { i18nCallCount++ if (i18nCallCount > 1) { throw new Error('useI18n called more than once — regression bug runtime') } return { t: (key: string) => key } }) mockPatch.mockResolvedValueOnce({}) const { syncFromAuth, switchSite } = useCurrentSite() syncFromAuth() // Si switchSite appelait useI18n() en interne, ce call incrementerait // i18nCallCount a 2 et throw. La garde du test passe uniquement si // la capture a bien eu lieu au setup (i18nCallCount reste a 1). await switchSite(SITE_B) expect(i18nCallCount).toBe(1) // Restaure le stub par defaut pour les tests suivants. vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) }) })