Module sites (#8)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

| 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:
2026-04-20 15:31:58 +00:00
committed by Autin
parent 6b4868b261
commit 6cf5ef4cfc
77 changed files with 7739 additions and 80 deletions

View 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)
})
})

View 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)
})
})

View 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,
}
}

View File

@@ -1,3 +1,4 @@
import { ref } from 'vue'
import type { SidebarSection } from '~/shared/types'
const sections = ref<SidebarSection[]>([])

View File

@@ -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
}
}
}
})

View File

@@ -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 {

View 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
}

View File

@@ -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
}

View 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)
})
})

View 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)
}