- logout.vue : navigateTo('/login') dans le finally, garanti meme si
auth.logout() rejette.
- auth.ts : systeme de callbacks onAuthSessionCleared appeles par
clearSession() (intercepteur 401 de useApi). Les composables modules
s'abonnent pour reset leur state sans que Shared n'importe depuis
modules/ (Option C validee par CLAUDE.md, module -> shared autorise).
- useCurrentSite.ts : enregistre un reset callback + apres un switch
reussi, rafraichit useSidebar().loadSidebar() + refreshNuxtData()
(sinon donnees de page obsoletes cote ancien site sous toast success).
- SiteSelector.vue : le court-circuit "tile deja active" est retire
pour permettre un PATCH de resync quand un autre onglet a bascule le
site entre temps. TODO cross-tab : ecouter un storage event dedie.
- sites.vue admin : auth.refreshUser() apres delete pour refleter le
ON DELETE SET NULL cote user.current_site_id.
- Specs vitest : stub useSidebar/refreshNuxtData, test "tile active"
retourne sur le nouveau contrat PATCH-toujours.
220 lines
7.5 KiB
TypeScript
220 lines
7.5 KiB
TypeScript
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 }))
|
|
})
|
|
})
|