- 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.
190 lines
7.0 KiB
TypeScript
190 lines
7.0 KiB
TypeScript
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)
|
|
})
|
|
})
|