| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: MALIO-DEV/Coltura#8 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #8.
This commit is contained in:
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useModules } from '../useModules'
|
||||
|
||||
// Mock de useApi : on peut scripter la reponse de /api/modules.
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le
|
||||
// mock comme global pour que l'appel sans import dans useModules.ts
|
||||
// (pattern aligne sur useSidebar) fonctionne.
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
describe('useModules', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
// Reset l'etat singleton entre tests.
|
||||
const { resetModules } = useModules()
|
||||
resetModules()
|
||||
})
|
||||
|
||||
it('charge la liste des modules actifs depuis /api/modules', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, activeModuleIds, loaded } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false })
|
||||
expect(activeModuleIds.value).toEqual(['core', 'sites'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne true pour un id present', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(true)
|
||||
expect(isModuleActive('core')).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne false pour un id absent', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
expect(isModuleActive('inexistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau et laisse la liste vide', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetModules vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, resetModules, activeModuleIds, loaded } = useModules()
|
||||
await loadModules()
|
||||
expect(activeModuleIds.value.length).toBeGreaterThan(0)
|
||||
|
||||
resetModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSidebar } from '../useSidebar'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests de l'invariant "loadSidebar ne reject jamais".
|
||||
*
|
||||
* Garantie utilisee par le middleware auth.global.ts qui fait un
|
||||
* Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le
|
||||
* middleware echoue et toute l'app avec. Le swallow interne est donc
|
||||
* load-bearing et ce test le verrouille.
|
||||
*/
|
||||
describe('useSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
const { resetSidebar } = useSidebar()
|
||||
resetSidebar()
|
||||
})
|
||||
|
||||
it('charge sections et disabledRoutes depuis /api/sidebar', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
await loadSidebar()
|
||||
|
||||
expect(sections.value).toHaveLength(1)
|
||||
expect(disabledRoutes.value).toEqual(['/foo'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
// Assertion principale : la promise resout normalement meme sur erreur.
|
||||
await expect(loadSidebar()).resolves.toBeUndefined()
|
||||
expect(sections.value).toEqual([])
|
||||
expect(disabledRoutes.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isRouteDisabled matche exactement un chemin', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] })
|
||||
const { loadSidebar, isRouteDisabled } = useSidebar()
|
||||
await loadSidebar()
|
||||
|
||||
expect(isRouteDisabled('/foo')).toBe(true)
|
||||
expect(isRouteDisabled('/foo/bar')).toBe(true)
|
||||
expect(isRouteDisabled('/other')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetSidebar vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, resetSidebar, sections, loaded } = useSidebar()
|
||||
await loadSidebar()
|
||||
expect(loaded.value).toBe(true)
|
||||
|
||||
resetSidebar()
|
||||
|
||||
expect(sections.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
49
frontend/shared/composables/useModules.ts
Normal file
49
frontend/shared/composables/useModules.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||
*
|
||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
||||
* (voir pages/logout.vue).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>(
|
||||
'/modules',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// Swallow volontaire aligne sur useSidebar : un echec reseau ne
|
||||
// doit pas bloquer le rendu, l'app affichera juste sans la
|
||||
// granularite module (selector masque par defaut).
|
||||
activeModuleIds.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
activeModuleIds,
|
||||
loaded,
|
||||
loadModules,
|
||||
isModuleActive,
|
||||
resetModules,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SidebarSection } from '~/shared/types'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/shared/types/user-data'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { getCurrentUser, login, logout } from '~/shared/services/auth'
|
||||
|
||||
/**
|
||||
* Callbacks enregistres par les composables singletons qui doivent
|
||||
* reinitialiser leur etat quand la session est invalidee (ex: expiration
|
||||
* JWT, logout depuis un intercepteur 401). Utilise le pattern
|
||||
* "callback registration" (Option C) pour eviter une dependance croisee
|
||||
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
|
||||
*/
|
||||
const onSessionClearedCallbacks: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
|
||||
* Typiquement invoque au setup-time du composable (module-level), donc
|
||||
* une seule fois par instance de composable singleton.
|
||||
*/
|
||||
export function onAuthSessionCleared(cb: () => void): void {
|
||||
onSessionClearedCallbacks.push(cb)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as UserData | null,
|
||||
@@ -16,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
// Notifie les composables singletons (useCurrentSite, etc.) afin
|
||||
// qu'ils reinitialisation leur etat — necessaire quand la session
|
||||
// est invalidee par un intercepteur 401 sans passer par logout.vue.
|
||||
onSessionClearedCallbacks.forEach((cb) => cb())
|
||||
},
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
@@ -66,6 +89,18 @@ export const useAuthStore = defineStore('auth', {
|
||||
} catch {
|
||||
// Silently fail — user session might have expired
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Action dediee au switch du site courant (ticket 3 module Sites).
|
||||
* Utilisee par useCurrentSite apres la confirmation serveur, et en
|
||||
* rollback si la requete PATCH echoue apres une mutation optimistic.
|
||||
* Passer explicitement par une action plutot que muter user.currentSite
|
||||
* directement garantit la tracabilite Pinia (devtools).
|
||||
*/
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (this.user) {
|
||||
this.user.currentSite = site
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface UserListItem {
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
directPermissions: string[]
|
||||
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||
sites: string[]
|
||||
}
|
||||
|
||||
export interface EffectivePermission {
|
||||
|
||||
24
frontend/shared/types/sites.ts
Normal file
24
frontend/shared/types/sites.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
|
||||
* (computed cote backend) ni champs read-only (id, timestamps).
|
||||
*/
|
||||
export interface SiteInput {
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Site } from './sites'
|
||||
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
@@ -6,4 +8,8 @@ export interface UserData {
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
/** Sites autorises pour l'utilisateur (ticket 2 module Sites). */
|
||||
sites: Site[]
|
||||
/** Site actuellement selectionne par l'utilisateur, ou null si aucun. */
|
||||
currentSite: Site | null
|
||||
}
|
||||
|
||||
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidSiteColor } from '../color'
|
||||
|
||||
describe('isValidSiteColor', () => {
|
||||
it('accepte un hex majuscule', () => {
|
||||
expect(isValidSiteColor('#ABCDEF')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex minuscule', () => {
|
||||
expect(isValidSiteColor('#abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex mixte', () => {
|
||||
expect(isValidSiteColor('#0a1B2c')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte les couleurs fixtures du projet', () => {
|
||||
expect(isValidSiteColor('#056CF2')).toBe(true)
|
||||
expect(isValidSiteColor('#F3CB00')).toBe(true)
|
||||
expect(isValidSiteColor('#74BF04')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette un nom CSS', () => {
|
||||
expect(isValidSiteColor('red')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex court', () => {
|
||||
expect(isValidSiteColor('#FFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex sans diese', () => {
|
||||
expect(isValidSiteColor('FFFFFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un caractere non hex', () => {
|
||||
expect(isValidSiteColor('#12345G')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette une chaine vide', () => {
|
||||
expect(isValidSiteColor('')).toBe(false)
|
||||
})
|
||||
})
|
||||
19
frontend/shared/utils/color.ts
Normal file
19
frontend/shared/utils/color.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Utilitaires de couleur partages.
|
||||
*
|
||||
* Aligne sur la regex backend stricte #RRGGBB (voir Site.php).
|
||||
*/
|
||||
|
||||
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
|
||||
|
||||
/**
|
||||
* Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres,
|
||||
* 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules,
|
||||
* minuscules, mixte).
|
||||
*
|
||||
* Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi
|
||||
* backend — miroir du pattern Symfony Assert\Regex sur Site::$color.
|
||||
*/
|
||||
export function isValidSiteColor(hex: string): boolean {
|
||||
return HEX_COLOR_REGEX.test(hex)
|
||||
}
|
||||
Reference in New Issue
Block a user