Module sites #8

Merged
tristan merged 9 commits from feat/module-site-backend into develop 2026-04-20 15:31:59 +00:00
32 changed files with 2271 additions and 117 deletions
Showing only changes of commit d137828919 - Show all commits

View File

@@ -48,6 +48,13 @@ return [
'module' => 'core',
'permission' => 'core.users.view',
],
[
'label' => 'sidebar.core.sites',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
[
'label' => 'sidebar.general.logout',
'to' => '/logout',

View File

@@ -25,7 +25,8 @@
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs"
"users": "Utilisateurs",
"sites": "Sites"
}
},
"dashboard": {
@@ -54,6 +55,9 @@
"put": "Erreur lors de la mise a jour",
"patch": "Erreur lors de la modification",
"delete": "Erreur lors de la suppression"
},
"sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
}
},
"success": {
@@ -102,7 +106,8 @@
"username": "Nom d'utilisateur",
"admin": "Administrateur",
"roles": "Roles",
"directPermissions": "Permissions directes"
"directPermissions": "Permissions directes",
"sites": "Sites"
},
"drawer": {
"title": "Permissions de {username}",
@@ -110,6 +115,7 @@
"adminToggle": "Administrateur (bypass total)",
"rolesSection": "Rôles",
"directPermissionsSection": "Permissions directes",
"sitesSection": "Sites autorisés",
"summarySection": "Résumé des permissions effectives",
"noEffectivePermissions": "Aucune permission effective",
"sourceRole": "via {role}",
@@ -119,6 +125,39 @@
"toast": {
"updated": "Permissions mises à jour avec succès"
}
},
"sites": {
"title": "Gestion des sites",
"newSite": "Nouveau site",
"editSite": "Modifier le site",
"createSite": "Créer un site",
"noSites": "Aucun site configuré",
"table": {
"name": "Nom",
"city": "Ville",
"postalCode": "Code postal",
"color": "Couleur",
"fullAddress": "Adresse complète"
},
"form": {
"name": "Nom",
"street": "Rue",
"complement": "Complément d'adresse",
"complementPlaceholder": "Bâtiment, escalier, BP... (optionnel)",
"postalCode": "Code postal",
"city": "Ville",
"color": "Couleur (format #RRGGBB)",
"colorInvalid": "Format attendu : #RRGGBB (6 caractères hexadécimaux)"
},
"delete": {
"title": "Supprimer le site",
"message": "Êtes-vous sûr de vouloir supprimer le site \"{name}\" ? Cette action est irréversible et retirera ce site à tous les utilisateurs rattachés."
},
"toast": {
"created": "Site créé avec succès",
"updated": "Site mis à jour avec succès",
"deleted": "Site supprimé avec succès"
}
}
}
}

View File

@@ -64,6 +64,27 @@
</div>
</div>
<!-- Section Sites autorises (ticket 2 module Sites) -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.sitesSection') }}
</h4>
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
{{ t('admin.sites.noSites') }}
</div>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="site in allSites"
:id="`site-${site.id}`"
:key="site.id"
:label="site.name"
:model-value="selectedSiteIds.has(site.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
/>
</div>
</div>
<!-- Section Resume permissions effectives -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
@@ -92,6 +113,7 @@
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
interface PermissionModule {
module: string
@@ -115,10 +137,12 @@ const emit = defineEmits<{
const saving = ref(false)
const allRoles = ref<Role[]>([])
const allPermissions = ref<Permission[]>([])
const allSites = ref<Site[]>([])
const form = ref({ isAdmin: false })
const selectedRoleIds = ref(new Set<number>())
const selectedDirectPermissionIds = ref(new Set<number>())
const selectedSiteIds = ref(new Set<number>())
// Detecter l'auto-edition
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
@@ -182,14 +206,17 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles et permissions
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
// a l'ouverture du drawer.
async function loadData() {
const [rolesData, permsData] = await Promise.all([
const [rolesData, permsData, sitesData] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
}
// Remplir le formulaire quand le user change
@@ -198,10 +225,12 @@ watch(() => props.user, (user) => {
form.value.isAdmin = user.isAdmin
selectedRoleIds.value = new Set(user.roles.map(iriToId))
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
} else {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
}
}, { immediate: true })
@@ -235,6 +264,13 @@ function handleToggleAll(module: string, selected: boolean) {
selectedDirectPermissionIds.value = ids
}
function toggleSite(id: number, selected: boolean) {
const ids = new Set(selectedSiteIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedSiteIds.value = ids
}
async function handleSave() {
if (!props.user) return
saving.value = true
@@ -243,6 +279,7 @@ async function handleSave() {
isAdmin: form.value.isAdmin,
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
sites: Array.from(selectedSiteIds.value).map(id => `/api/sites/${id}`),
}, {
toastSuccessMessage: t('admin.users.toast.updated'),
})

View File

@@ -38,6 +38,7 @@
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
@@ -48,6 +49,7 @@ useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
@@ -57,8 +59,14 @@ const columns = [
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
const userItems = computed(() =>
users.value.map(user => ({
id: user.id,
@@ -66,18 +74,27 @@ const userItems = computed(() =>
admin: user.isAdmin,
roles: user.roles.length,
directPermissions: user.directPermissions.length,
}))
// Affichage : liste des noms de sites separes par virgule. Les IRIs
// du payload /api/users (groupe user:list) sont resolues via la Map
// construite en parallele depuis /api/sites.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
.join(', '),
})),
)
async function loadUsers() {
loading.value = true
try {
const data = await api.get<{ member: UserListItem[] }>(
'/users',
{},
{ toast: false },
)
users.value = data.member
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} finally {
loading.value = false
}

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.sites.delete.title') }}
</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ t('admin.sites.delete.message', { name: siteName }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="cancel"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
siteName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
emit('confirm')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.street"
:label="t('admin.sites.form.street')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.complement"
:label="t('admin.sites.form.complement')"
:placeholder="t('admin.sites.form.complementPlaceholder')"
input-class="w-full"
/>
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
maxLength en double securite. La regex backend validera la
forme finale, le masque empeche juste la saisie de
caracteres non numeriques. -->
<MalioInputText
v-model="form.postalCode"
:label="t('admin.sites.form.postalCode')"
input-class="w-full"
mask="#####"
max-length="5"
required
/>
<MalioInputText
v-model="form.city"
:label="t('admin.sites.form.city')"
input-class="w-full"
required
/>
<!-- Champ couleur avec preview puce -->
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">
{{ t('admin.sites.form.color') }}
</label>
<div class="flex items-center gap-3">
<MalioInputText
v-model="form.color"
placeholder="#RRGGBB"
input-class="w-full font-mono"
required
/>
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }}
</p>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
import { isValidSiteColor } from '~/modules/sites/utils/color'
const { t } = useI18n()
const api = useApi()
const props = defineProps<{
modelValue: boolean
site: Site | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
const form = ref({
name: '',
street: '',
complement: '',
postalCode: '',
city: '',
color: '#000000',
})
const isEditMode = computed(() => props.site !== null)
// Validation locale du format hex #RRGGBB avant envoi backend.
const isValidHex = computed(() => isValidSiteColor(form.value.color))
// Remplir le formulaire quand le site change
watch(() => props.site, (site) => {
if (site) {
form.value.name = site.name
form.value.street = site.street
form.value.complement = site.complement ?? ''
form.value.postalCode = site.postalCode
form.value.city = site.city
form.value.color = site.color
} else {
form.value.name = ''
form.value.street = ''
form.value.complement = ''
form.value.postalCode = ''
form.value.city = ''
form.value.color = '#056CF2'
}
}, { immediate: true })
async function handleSave() {
if (!isValidHex.value) return
saving.value = true
try {
// Le champ complement est optionnel cote DB : on envoie null si vide
// pour que le backend stocke NULL plutot qu'une chaine vide.
const trimmedComplement = form.value.complement.trim()
const payload = {
name: form.value.name,
street: form.value.street,
complement: trimmedComplement === '' ? null : trimmedComplement,
postalCode: form.value.postalCode,
city: form.value.city,
color: form.value.color,
}
if (isEditMode.value && props.site) {
await api.patch(`/sites/${props.site.id}`, payload, {
toastSuccessMessage: t('admin.sites.toast.updated'),
})
} else {
await api.post('/sites', payload, {
toastSuccessMessage: t('admin.sites.toast.created'),
})
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1 @@
export default defineNuxtConfig({})

View File

@@ -0,0 +1,164 @@
<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.sites.title') }}
</h1>
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des sites -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
<span
:style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200"
/>
<span class="font-mono text-xs">{{ item.color }}</span>
</span>
</template>
<template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-xs text-neutral-600">
{{ item.fullAddress }}
</span>
</template>
</MalioDataTable>
<!-- Drawer creation/edition -->
<SiteDrawer
v-model="drawerOpen"
:site="selectedSite"
@saved="onSiteSaved"
@delete="onDeleteRequest"
/>
<!-- Modale de suppression -->
<SiteDeleteModal
v-model="deleteModalOpen"
:site-name="siteToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
const sites = ref<Site[]>([])
const loading = ref(false)
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
{ key: 'city', label: t('admin.sites.table.city') },
{ key: 'postalCode', label: t('admin.sites.table.postalCode') },
{ key: 'color', label: t('admin.sites.table.color') },
{ key: 'fullAddress', label: t('admin.sites.table.fullAddress') },
]
// Transformer les sites en items compatibles MalioDataTable.
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
const siteItems = computed(() =>
sites.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
postalCode: site.postalCode,
color: site.color,
fullAddress: site.fullAddress.split('\n').join(', '),
})),
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const site = getSiteById(item.id as number)
if (site) openEditDrawer(site)
}
const drawerOpen = ref(false)
const selectedSite = ref<Site | null>(null)
const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
}
function openEditDrawer(site: Site) {
selectedSite.value = site
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedSite.value) return
siteToDelete.value = selectedSite.value
deleteModalOpen.value = true
}
async function handleDelete() {
if (!siteToDelete.value) return
deleting.value = true
try {
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.sites.toast.deleted'),
})
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
Review

Après suppression, loadSites() rafraîchit la liste mais pas auth.user. Si l'admin supprime son site courant, la backend passe user.current_site_id à NULL (cascade ON DELETE SET NULL de la FK) mais auth.user.currentSite côté Pinia reste le site supprimé. Le SiteSelector du header continue d'afficher la tile morte jusqu'au prochain /api/me.

Fix : await auth.fetchUser() (ou équivalent) après le loadSites() dans le try block, ou invalider currentSite à la main si l'id supprimé === auth.user.currentSite?.id.

Après suppression, `loadSites()` rafraîchit la liste mais pas `auth.user`. Si l'admin supprime son site courant, la backend passe `user.current_site_id` à NULL (cascade `ON DELETE SET NULL` de la FK) mais `auth.user.currentSite` côté Pinia reste le site supprimé. Le `SiteSelector` du header continue d'afficher la tile morte jusqu'au prochain `/api/me`. Fix : `await auth.fetchUser()` (ou équivalent) après le `loadSites()` dans le `try` block, ou invalider `currentSite` à la main si l'id supprimé === `auth.user.currentSite?.id`.
} finally {
deleting.value = false
}
}
function onSiteSaved() {
loadSites()
}
onMounted(() => {
loadSites()
})
</script>

View File

@@ -0,0 +1,50 @@
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('#10B981')).toBe(true)
expect(isValidSiteColor('#F59E0B')).toBe(true)
})
it('rejette un nom CSS', () => {
expect(isValidSiteColor('red')).toBe(false)
})
it('rejette un hex court (3 caracteres)', () => {
expect(isValidSiteColor('#FFF')).toBe(false)
})
it('rejette un hex sans diese', () => {
expect(isValidSiteColor('FFFFFF')).toBe(false)
})
it('rejette un format rgb()', () => {
expect(isValidSiteColor('rgb(255, 0, 0)')).toBe(false)
})
it('rejette un hex trop long', () => {
expect(isValidSiteColor('#1234567')).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,15 @@
/**
* Validation du format de couleur d'un site.
*
* Aligne sur la regex backend (Site entity) : seul le format #RRGGBB
* strict est accepte, avec 6 caracteres hexadecimaux apres le #.
* Tolere la casse (majuscules, minuscules, mixte).
*
* Utilise par SiteDrawer pour bloquer le submit cote front avant qu'une
* requete invalide parte au backend.
*/
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
export function isValidSiteColor(hex: string): boolean {
return HEX_COLOR_REGEX.test(hex)
}

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

@@ -39,6 +39,11 @@ final class Version20260417120000 extends AbstractMigration
// - `postal_code` est limite a 10 caracteres pour laisser marge a
// d'eventuels formats etrangers plus tard, tout en le validant
// strictement en 5 chiffres cote applicatif (format FR).
//
// Note : `full_address` est restructure au ticket 2 (migration
// Version20260420130000) en `street` + `complement` (nullable). La
// structure d'origine est conservee ici pour ne pas casser les devs
// qui ont deja joue cette migration en local.
$this->addSql(<<<'SQL'
CREATE TABLE site (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Module Sites - Ticket 2/4 : rattachement User ↔ Site.
*
* Introduit deux nouvelles structures sur le schema existant :
* - la table de jointure `user_site` (M2M) : liste des sites autorises
* pour chaque utilisateur.
* - la colonne `"user".current_site_id` (M2O nullable) : site actuellement
* selectionne par l'utilisateur pour son contexte UX.
*
* Cascades choisies :
* - `user_site.user_id` → `ON DELETE CASCADE` : supprimer un user purge
* naturellement ses rattachements.
* - `user_site.site_id` → `ON DELETE CASCADE` : supprimer un site purge
* tous les rattachements a ce site.
* - `"user".current_site_id` → `ON DELETE SET NULL` : supprimer un site
* repasse le currentSite des users concernes a NULL (plutot que de
* detruire les users, ce qui serait catastrophique).
*
* Note sur l'emplacement du fichier (namespace racine `DoctrineMigrations`)
* Conforme a l'exception documentee dans `CLAUDE.md` : tant que le bug de
* tri alphabetique des MigrationsComparator Doctrine 3.x n'est pas resolu,
* toute migration touchant a la topologie des tables (creation, FKs
* cross-module) vit au namespace racine. La migration croise ici les tables
* `"user"` (module Core) et `site` (module Sites) — placement racine donc
* justifie pour garantir l'ordre d'execution deterministe vis-a-vis des
* deux migrations d'init deja presentes.
*/
final class Version20260417150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Module Sites : table user_site (M2M) + colonne user.current_site_id (M2O SET NULL).';
}
public function up(Schema $schema): void
{
// 1) Creation de la table de jointure user_site.
$this->addSql(<<<'SQL'
CREATE TABLE user_site (
user_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (user_id, site_id)
)
SQL);
$this->addSql('CREATE INDEX IDX_user_site_user ON user_site (user_id)');
$this->addSql('CREATE INDEX IDX_user_site_site ON user_site (site_id)');
$this->addSql(<<<'SQL'
ALTER TABLE user_site
ADD CONSTRAINT FK_user_site_user
FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_site
ADD CONSTRAINT FK_user_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
SQL);
// 2) Ajout de la colonne nullable user.current_site_id + FK SET NULL.
$this->addSql('ALTER TABLE "user" ADD current_site_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_user_current_site ON "user" (current_site_id)');
$this->addSql(<<<'SQL'
ALTER TABLE "user"
ADD CONSTRAINT FK_user_current_site
FOREIGN KEY (current_site_id) REFERENCES site (id) ON DELETE SET NULL
SQL);
}
public function down(Schema $schema): void
{
// Rollback en ordre inverse : enfants avant parents.
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_user_current_site');
$this->addSql('DROP INDEX IDX_user_current_site');
$this->addSql('ALTER TABLE "user" DROP current_site_id');
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_site');
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_user');
$this->addSql('DROP TABLE user_site');
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Module Sites - Ticket 2/4 : restructuration de l'adresse.
*
* Splitte la colonne `site.full_address` (TEXT NOT NULL, multi-lignes) en
* deux champs structures :
* - `street` (VARCHAR(255) NOT NULL) : numero + voie ;
* - `complement` (VARCHAR(255) DEFAULT NULL) : batiment, escalier, BP...
*
* L'adresse complete affichable est desormais reconstituee cote applicatif
* par Site::getFullAddress() (concatenation multi-lignes street\n[complement\n]CP ville)
* et exposee en lecture API via le groupe `site:read` + `me:read`. Plus de
* colonne DB redondante.
*
* Strategie de backfill (entre creation des nouvelles colonnes et drop de
* l'ancienne) :
* - `street` recoit la totalite de l'ancien `full_address` pour ne perdre
* aucune donnee. C'est imparfait pour les adresses multi-lignes mais
* safe : aucun risque de tronquage si l'ancienne adresse depasse 255
* chars (PostgreSQL leve une erreur explicite ; charge a l'admin de
* nettoyer manuellement si necessaire).
* - `complement` reste null : pas d'heuristique fiable pour decouper une
* adresse libre en street/complement.
*
* Cette migration evite un `make db-reset` force pour les developpeurs
* ayant deja joue Version20260417120000 dans son etat initial (table site
* avec full_address). Les fixtures sont mises a jour en parallele.
*/
final class Version20260420130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Module Sites : split full_address en street + complement (getter computed cote applicatif).';
}
public function up(Schema $schema): void
{
// 1) Ajout des nouvelles colonnes en mode permissif :
// - `street` nullable temporairement pour permettre le backfill.
// - `complement` definitivement nullable.
$this->addSql('ALTER TABLE site ADD street VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE site ADD complement VARCHAR(255) DEFAULT NULL');
// 2) Backfill : recopier full_address dans street pour ne pas perdre
// les donnees existantes. Les retours a la ligne sont preserves
// (PostgreSQL VARCHAR accepte \n) ; un admin pourra reformater
// apres coup si besoin. Cas d'adresse > 255 chars : la migration
// echoue cleanly (pas de tronquage silencieux).
$this->addSql('UPDATE site SET street = full_address');
// 3) Bascule street en NOT NULL une fois le backfill applique.
$this->addSql('ALTER TABLE site ALTER COLUMN street SET NOT NULL');
// 4) Drop de l'ancienne colonne full_address.
$this->addSql('ALTER TABLE site DROP full_address');
}
public function down(Schema $schema): void
{
// Recreation de full_address (NOT NULL via DEFAULT '' pour eviter
// un crash si la table a deja des lignes), puis backfill inverse,
// puis drop des nouvelles colonnes.
$this->addSql("ALTER TABLE site ADD full_address TEXT NOT NULL DEFAULT ''");
$this->addSql("UPDATE site SET full_address = street || COALESCE(E'\\n' || complement, '')");
$this->addSql('ALTER TABLE site ALTER COLUMN full_address DROP DEFAULT');
$this->addSql('ALTER TABLE site DROP street');
$this->addSql('ALTER TABLE site DROP complement');
}
}

View File

@@ -15,6 +15,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\Sites\Domain\Entity\Site;

Import direct CoreSites (entité + exception) : viole la règle CLAUDE.md « jamais d'import direct entre modules » (ligne 138). Piste : introduire une interface SiteInterface dans Shared/Domain/Contract/ et typer User::$currentSite / hasSite() / switchCurrentSite() contre cette interface. L'exception SiteNotAuthorizedException peut aussi être remontée dans Shared/Domain/Exception/.

Import direct `Core` → `Sites` (entité + exception) : viole la règle CLAUDE.md « jamais d'import direct entre modules » (ligne 138). Piste : introduire une interface `SiteInterface` dans `Shared/Domain/Contract/` et typer `User::$currentSite` / `hasSite()` / `switchCurrentSite()` contre cette interface. L'exception `SiteNotAuthorizedException` peut aussi être remontée dans `Shared/Domain/Exception/`.
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -107,6 +109,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]

4 relations EAGER (rbacRoles L89, directPermissions L107, sites L123, currentSite L140) toutes dans le groupe user:list. Sur GET /api/users avec pagination 30, c'est 1 + 120 requêtes par page (plus N+1 nested si role.permissions est EAGER).

Les docblocks justifient EAGER pour /api/me uniquement. Options :

  • Retirer sites/currentSite du groupe user:list (règle aussi le leak inter-site)
  • Passer les 4 relations en LAZY et JOIN FETCH dans MeProvider
  • Ou introduire un DTO UserListOutput minimal pour le listing.
4 relations EAGER (`rbacRoles` L89, `directPermissions` L107, `sites` L123, `currentSite` L140) toutes dans le groupe `user:list`. Sur `GET /api/users` avec pagination 30, c'est 1 + 120 requêtes par page (plus N+1 nested si `role.permissions` est EAGER). Les docblocks justifient EAGER pour `/api/me` uniquement. Options : - Retirer `sites`/`currentSite` du groupe `user:list` (règle aussi le leak inter-site) - Passer les 4 relations en LAZY et `JOIN FETCH` dans `MeProvider` - Ou introduire un DTO `UserListOutput` minimal pour le listing.
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]

Les groupes de sérialisation de sites exposent la collection au-delà du scope sites.* :

  • user:listGET /api/users (gardé par core.users.view) renvoie les IRIs des sites de chaque user. Quiconque peut lister les users apprend les appartenances, même sans sites.view.
  • user:rbac:writePATCH /api/users/{id}/rbac (gardé par core.users.manage) permet d'écrire sites sans sites.manage. Un user-manager peut ainsi s'ajouter/retirer à un site arbitraire puis basculer via /api/me/current-site.

Pistes : retirer sites de user:list (ou introduire un groupe user:list:full gardé par sites.view), et dans UserRbacProcessor refuser la mutation de sites si l'appelant n'a pas sites.manage.

Les groupes de sérialisation de `sites` exposent la collection au-delà du scope `sites.*` : - `user:list` → `GET /api/users` (gardé par `core.users.view`) renvoie les IRIs des sites de chaque user. Quiconque peut lister les users apprend les appartenances, même sans `sites.view`. - `user:rbac:write` → `PATCH /api/users/{id}/rbac` (gardé par `core.users.manage`) permet d'écrire `sites` sans `sites.manage`. Un user-manager peut ainsi s'ajouter/retirer à un site arbitraire puis basculer via `/api/me/current-site`. Pistes : retirer `sites` de `user:list` (ou introduire un groupe `user:list:full` gardé par `sites.view`), et dans `UserRbacProcessor` refuser la mutation de `sites` si l'appelant n'a pas `sites.manage`.
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*/
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])]
private ?Site $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -121,6 +156,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->sites = new ArrayCollection();
}
public function getId(): ?int
@@ -313,4 +349,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->plainPassword = null;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
/**
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*/
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
$site->addUser($this);
}
return $this;
}
/**
* Retire un site de la collection + maintient la collection inverse en
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
* si le site retire en etait le courant — cet invariant est enforce
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(Site $site): static
{
if ($this->sites->removeElement($site)) {
$site->removeUser($this);
}
return $this;
}
/**
* Garde applicative rapide : teste la presence d'un site dans la
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(Site $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?Site
{
return $this->currentSite;
}
/**
* Setter brut, sans garde. Usage interne pour les flux qui doivent
* pouvoir positionner un site arbitraire ou null (reset de coherence
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?Site $currentSite): static
{
$this->currentSite = $currentSite;
return $this;
}
/**
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
* dans la collection autorisee. Levee d'une exception domaine que le
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
* → SystemRoleDeletionException).
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(Site $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);
}
$this->currentSite = $site;
}
}

View File

@@ -29,6 +29,14 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ;
* b) est auto-selectionne sur le premier site de `sites` s'il etait
* null alors que la collection n'est pas vide (pratique pour un
* premier rattachement).
* Un second flush est emis uniquement si la coherence a du etre corrigee.
*
* @implements ProcessorInterface<User, User>
*/
@@ -80,6 +88,46 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement.
$this->ensureCurrentSiteConsistency($data);

Les deux flushs (L91 via persistProcessor + L130 via ensureCurrentSiteConsistency) ne sont pas wrappés dans la même transaction. Crash entre les deux (OOM, worker killed, perte de connexion DB) laisse currentSite pointer vers un site absent de user_site → viole l'invariant L34-39.

Fix : wrapper tout le process() dans $this->entityManager->wrapInTransaction(function () use (...) { ... }).

Les deux flushs (L91 via `persistProcessor` + L130 via `ensureCurrentSiteConsistency`) ne sont pas wrappés dans la même transaction. Crash entre les deux (OOM, worker killed, perte de connexion DB) laisse `currentSite` pointer vers un site absent de `user_site` → viole l'invariant L34-39. Fix : wrapper tout le `process()` dans `$this->entityManager->wrapInTransaction(function () use (...) { ... })`.
return $result;
}
/**
* Applique deux corrections post-persist sur `currentSite` :
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant).
*
* N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites.
*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {

Le bloc if (null === currentSite && !sites->isEmpty()) s'applique sur tout PATCH /rbac, même si la requête n'a pas touché sites. Scénario : un admin supprime un site → la FK passe à NULL pour les users concernés (onDelete: SET NULL). Plus tard, un manager édite seulement un rôle sur un de ces users → cet appel bascule silencieusement currentSite sur sites->first(), changeant le contexte effectif des lectures/écritures scopées.

Garder la garde (a) — currentSite retiré → null — mais conditionner (b) au fait que sites a réellement été modifié par la requête courante (ou ne l'appliquer que quand currentSite était non-null avant, ou seulement sur POST utilisateur).

Le bloc `if (null === currentSite && !sites->isEmpty())` s'applique sur **tout** PATCH `/rbac`, même si la requête n'a pas touché `sites`. Scénario : un admin supprime un site → la FK passe à NULL pour les users concernés (`onDelete: SET NULL`). Plus tard, un manager édite seulement un rôle sur un de ces users → cet appel bascule silencieusement `currentSite` sur `sites->first()`, changeant le contexte effectif des lectures/écritures scopées. Garder la garde (a) — `currentSite` retiré → null — mais conditionner (b) au fait que `sites` a réellement été modifié par la requête courante (ou ne l'appliquer que quand `currentSite` était non-null avant, ou seulement sur `POST` utilisateur).
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}
}

View File

@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034,
* puis (ticket 2 module Sites) rattaches a au moins un site avec un currentSite
* coherent.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
* casse l'independance declaree au ticket 1 : c'est un couplage assume car
* apres ticket 2 le modele metier exprime un besoin legitime de rattachement.
*/
class AppFixtures extends Fixture
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
// SitesFixtures doit tourner AVANT AppFixtures pour que les sites
// soient disponibles au rattachement des users ci-dessous.
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
'Role de base sans permission specifique',
);
// Recupere les 3 sites seedes par SitesFixtures. Si absents, c'est
// une misconfiguration (fixture hors purge ou dependance ignoree) :
// on fail fort avec un message explicite plutot que de continuer
// avec des users orphelins de site.
$chatellerault = $this->requireSite('Chatellerault');
$saintJean = $this->requireSite('Saint-Jean');
$pommevic = $this->requireSite('Pommevic');
$admin = new User();
$admin->setUsername('admin');
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
// Admin rattache aux 3 sites pour faciliter le dev / les tests manuels.
$admin->addSite($chatellerault);
$admin->addSite($saintJean);
$admin->addSite($pommevic);
$admin->setCurrentSite($chatellerault);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
// Alice : un seul site, site courant = ce site.
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
// Bob : site different de Alice, pour prouver le filtrage par site
// dans les futurs tests (ticket 4 outillage SiteAware).
$bob->addSite($saintJean);
$bob->setCurrentSite($saintJean);
$manager->persist($bob);
$manager->flush();
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
return $role;
}
private function requireSite(string $name): Site
{
$site = $this->siteRepository->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(
'SitesFixtures doit avoir seede le site "%s" avant le chargement des users. '
.'Verifier que SitesFixtures est bien en dependance de AppFixtures.',
$name,
));
}
return $site;
}
}

View File

@@ -4,22 +4,63 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
*
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
* est creee meme si le module est desactive (voir migration dediee).
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
* permettre des recherches/tris fins ulterieurs et eviter les divergences
* entre champs duplique. La methode `getFullAddress()` fournit la version
* concatenee multi-lignes pour les usages d'affichage.
*
* Expose en API Platform pour l'administration CRUD avec RBAC :
* - lecture (GET list / item) : requiert la permission `sites.view`
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
*
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
* le frontend connaisse les sites autorises et le site courant de l'user.
*/
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
Review

GetCollection + Get sur /api/sites sont gardés par sites.view mais sans filtre sur $user->getSites(). Un délégataire de sites.view lit tous les sites de l'instance — nom, adresse, CP, ville, couleur — y compris ceux auxquels il n'a pas accès.

Fix : ajouter un QueryCollectionExtensionInterface + QueryItemExtensionInterface spécifique à Site qui restreint à $user->getSites(), sauf is_granted('sites.bypass_scope'). Pattern identique à SiteScopedQueryExtension mais ciblé sur Site lui-même.

`GetCollection` + `Get` sur `/api/sites` sont gardés par `sites.view` mais sans filtre sur `$user->getSites()`. Un délégataire de `sites.view` lit tous les sites de l'instance — nom, adresse, CP, ville, couleur — y compris ceux auxquels il n'a pas accès. Fix : ajouter un `QueryCollectionExtensionInterface` + `QueryItemExtensionInterface` spécifique à Site qui restreint à `$user->getSites()`, sauf `is_granted('sites.bypass_scope')`. Pattern identique à `SiteScopedQueryExtension` mais ciblé sur Site lui-même.
),
new Get(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Post(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Patch(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Delete(security: "is_granted('sites.manage')"),
],
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
@@ -30,17 +71,27 @@ class Site
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['site:read', 'me:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $name;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
private string $city;
// Premiere ligne d'adresse : numero + voie. Requise.
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est requise.')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $street;
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private ?string $complement = null;
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
// colonnes en minuscules dans le SQL brut). Le format est contraint au
@@ -52,50 +103,71 @@ class Site
pattern: '/^\d{5}$/',
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $postalCode;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $city;
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
// distinguer les sites d'un coup d'oeil.
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
// les sites d'un coup d'oeil.
#[ORM\Column(length: 7)]
#[Assert\NotBlank(message: 'La couleur est requise.')]
#[Assert\Regex(
pattern: '/^#[0-9A-Fa-f]{6}$/',
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $color;
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
// lignes (voie + complement + mention particuliere). Borne haute a 500
// caracteres : une adresse francaise complete tient tres largement dans
// cette enveloppe, et la limite applicative protege contre les payloads
// anormalement volumineux envoyes par un client (garde DoS basique).
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
#[Assert\Length(max: 500, maxMessage: 'L\'adresse complete ne peut pas depasser {{ limit }} caracteres.')]
private string $fullAddress;
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $updatedAt;
/**
* Collection inverse des users rattaches a ce site.
*
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
* l'API pour deux raisons :
* - eviter une boucle de serialisation infinie User → sites → users → ...
* si un jour un developpeur ajoute `me:read` ici par megarde ;
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
* iterer en test de cascade).
*
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
private Collection $users;
public function __construct(
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
) {
$this->name = $name;
$this->city = $city;
$this->postalCode = $postalCode;
$this->color = $color;
$this->fullAddress = $fullAddress;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->name = $name;
$this->street = $street;
$this->complement = $complement;
$this->postalCode = $postalCode;
$this->city = $city;
$this->color = $color;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->users = new ArrayCollection();
}
/**
@@ -125,14 +197,26 @@ class Site
return $this;
}
public function getCity(): string
public function getStreet(): string
{
return $this->city;
return $this->street;
}
public function setCity(string $city): static
public function setStreet(string $street): static
{
$this->city = $city;
$this->street = $street;
return $this;
}
public function getComplement(): ?string
{
return $this->complement;
}
public function setComplement(?string $complement): static
{
$this->complement = $complement;
return $this;
}
@@ -149,6 +233,18 @@ class Site
return $this;
}
public function getCity(): string
{
return $this->city;
}
public function setCity(string $city): static
{
$this->city = $city;
return $this;
}
public function getColor(): string
{
return $this->color;
@@ -161,16 +257,26 @@ class Site
return $this;
}
/**
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
* separes par des sauts de ligne. Methode pure, jamais persistee.
*
* Expose en lecture API (groupes site:read + me:read) pour que les
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
* afficher sans dupliquer la logique de concatenation cote client.
*/
#[Groups(['site:read', 'me:read'])]
public function getFullAddress(): string
{
return $this->fullAddress;
}
$lines = [$this->street];
public function setFullAddress(string $fullAddress): static
{
$this->fullAddress = $fullAddress;
if (null !== $this->complement && '' !== trim($this->complement)) {
$lines[] = $this->complement;
}
return $this;
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
return implode("\n", $lines);
}
public function getCreatedAt(): DateTimeImmutable
@@ -182,4 +288,39 @@ class Site
{
return $this->updatedAt;
}
/**
* @return Collection<int, User>
*/
public function getUsers(): Collection
{
return $this->users;
}
/**
* Synchronise la collection inverse cote Site quand User::addSite est
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
* eviter une recursion infinie : User::addSite est le point d'entree
* unique de la mutation.
*
* @internal Appele uniquement par User::addSite()
*/
public function addUser(User $user): static
{
if (!$this->users->contains($user)) {
$this->users->add($user);
}
return $this;
}
/**
* @internal Appele uniquement par User::removeSite()
*/
public function removeUser(User $user): static
{
$this->users->removeElement($user);
return $this;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Domain\Exception;
use App\Module\Sites\Domain\Entity\Site;
use DomainException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
*/
final class SiteNotAuthorizedException extends DomainException
{
public static function forSite(Site $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\CurrentSiteProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Ressource API Platform virtuelle (non mappee Doctrine) qui porte
* l'operation `PATCH /api/me/current-site` : basculement du site courant
* de l'utilisateur authentifie.
*
* `read: false` informe API Platform qu'il ne doit pas tenter de charger
* une entite existante via un Provider — l'operation denormalise le payload
* directement dans cette ressource, puis CurrentSiteProcessor prend le relais.
*
* `shortName: 'CurrentSite'` : evite toute collision avec l'entite `Site`
* dans le routage et la documentation OpenAPI.
*
* Securite : l'autorisation "ROLE_USER" suffit au niveau voter — la verification
* fine (le site demande fait-il partie des sites autorises de l'user ?)
* est faite par CurrentSiteProcessor, car elle dependence de l'user
* authentifie, pas d'une permission statique.
*/
#[ApiResource(
shortName: 'CurrentSite',
operations: [
new Patch(
uriTemplate: '/me/current-site',
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['me:read']],
denormalizationContext: ['groups' => ['current-site:write']],
processor: CurrentSiteProcessor::class,
read: false,
priority: 1,
),
],
)]
final class CurrentSiteResource
{
/**
* Site cible du switch, denormalise depuis l'IRI envoye dans le body :
* `{ "site": "/api/sites/{id}" }`. Resolu automatiquement par
* l'IriConverter d'API Platform en instance de `Site`.
*/
#[Groups(['current-site:write'])]
public ?Site $site = null;
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor de l'operation `PATCH /api/me/current-site`.
*
* Flux :
* 1. Recupere l'user authentifie via Security.
* 2. Extrait le site cible depuis la ressource denormalisee.
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve
* SiteNotAuthorizedException convertie immediatement en 403.
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation
* par API Platform via les groupes `me:read` (payload identique a /api/me).
*
* @implements ProcessorInterface<CurrentSiteResource, User>
*/
final class CurrentSiteProcessor implements ProcessorInterface
{
public function __construct(
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CurrentSiteResource) {
throw new LogicException(sprintf(
'CurrentSiteProcessor attend une instance de %s, %s recu.',
CurrentSiteResource::class,
get_debug_type($data),
));
}
$user = $this->security->getUser();
if (!$user instanceof User) {
// security: "is_granted('ROLE_USER')" sur l'operation doit deja
// bloquer ce cas — garde defensive si la config change.
throw new AccessDeniedHttpException('Authentification requise pour changer de site courant.');
}
$targetSite = $data->site;
if (null === $targetSite) {
throw new BadRequestHttpException('Le champ "site" est requis.');
}
try {
$user->switchCurrentSite($targetSite);
Review

TOCTOU : switchCurrentSite() lit $this->sites en mémoire puis flush(), sans #[ORM\Version] ni lock.

Alice ∈ [S1,S2] PATCH current-site: S2. En parallèle, admin PATCH /users/alice/rbac retire S2. hasSite(S2) d'Alice est true (collection chargée avant révocation), le flush d'Alice écrase le current_site_id = NULL posé par ensureCurrentSiteConsistency → Alice termine avec currentSite=S2 sans ligne user_site.

Fix : $em->refresh($user) avant le check, ou #[ORM\Version] private int $version sur User + catch OptimisticLockException.

TOCTOU : `switchCurrentSite()` lit `$this->sites` en mémoire puis `flush()`, sans `#[ORM\Version]` ni lock. Alice ∈ [S1,S2] PATCH `current-site: S2`. En parallèle, admin PATCH `/users/alice/rbac` retire S2. `hasSite(S2)` d'Alice est true (collection chargée avant révocation), le flush d'Alice écrase le `current_site_id = NULL` posé par `ensureCurrentSiteConsistency` → Alice termine avec `currentSite=S2` sans ligne `user_site`. Fix : `$em->refresh($user)` avant le check, ou `#[ORM\Version] private int $version` sur User + catch `OptimisticLockException`.
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush();
return $user;
}
}

View File

@@ -40,38 +40,43 @@ class SitesFixtures extends Fixture
$this->ensureSite(
$manager,
name: 'Chatellerault',
city: 'Chatellerault',
street: "14 All. d'Argenson",
complement: null,
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
fullAddress: "1 avenue de l'Europe\n86100 Chatellerault",
);
// Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault.
// Note : le nom du site (identifier) ne reflete pas la ville reelle
// (Fontenet) — c'est une nomenclature interne client.
$this->ensureSite(
$manager,
name: 'Saint-Jean',
city: 'Saint-Jean-de-Sauves',
postalCode: '86330',
street: 'Z i',
complement: null,
postalCode: '17400',
city: 'Fontenet',
color: '#10B981',
fullAddress: "12 route de Poitiers\n86330 Saint-Jean-de-Sauves",
);
// Pommevic : ambre pour une troisieme teinte nettement distincte.
$this->ensureSite(
$manager,
name: 'Pommevic',
city: 'Pommevic',
street: '1 Av. Jean Duquesne',
complement: null,
postalCode: '82400',
city: 'Pommevic',
color: '#F59E0B',
fullAddress: "5 chemin des Peupliers\n82400 Pommevic",
);
$manager->flush();
}
/**
* Cree le site s'il n'existe pas encore, sinon re-aligne ville, code
* postal, couleur et adresse sur les valeurs de reference.
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
* code postal, ville et couleur sur les valeurs de reference.
*
* Note : le nom sert de cle de lookup (il est unique en base) et n'est
* donc pas resynchronise. Consequence : renommer un site dans la
@@ -81,24 +86,26 @@ class SitesFixtures extends Fixture
private function ensureSite(
ObjectManager $manager,
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
): Site {
$site = $this->siteRepository->findByName($name);
if (null === $site) {
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
$manager->persist($site);
return $site;
}
$site->setCity($city);
$site->setStreet($street);
$site->setComplement($complement);
$site->setPostalCode($postalCode);
$site->setCity($city);
$site->setColor($color);
$site->setFullAddress($fullAddress);
return $site;
}

View File

@@ -130,4 +130,34 @@ abstract class AbstractApiTestCase extends ApiTestCase
return ['username' => $username, 'password' => $password];
}
/**
* Skip le test courant si le module Sites est desactive dans
* `config/modules.php` de l'environnement de test.
*
* Mecanisme : on cherche la permission `sites.view` en base. Si le
* module Sites est desactive, `app:sync-permissions` aura marque cette
* permission comme orpheline et l'aura supprimee de la table — donc
* `findOneBy(['code' => 'sites.view'])` renvoie null.
*
* Quand utiliser ce helper : tests qui s'appuient sur
* `createUserWithPermission('sites.*')`. Les tests qui utilisent
* uniquement l'admin (qui bypass via isAdmin) n'en ont pas besoin :
* la classe Site reste mappee Doctrine et exposee via API Platform
* meme module desactive (mapping inconditionnel, decision assumee
* ticket 1).
*/
protected function skipIfSitesModuleDisabled(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$perm = $this->getEm()
->getRepository(Permission::class)
->findOneBy(['code' => 'sites.view'])
;
if (null === $perm) {
self::markTestSkipped('Module Sites desactive : permission sites.view introuvable en base.');
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests d'extension de l'endpoint PATCH /api/users/{id}/rbac pour assigner
* des sites a un user, avec les deux gardes post-persist :
* - si currentSite n'est plus dans sites → null ;
* - si currentSite null ET sites non vide → auto-select premier site.
*
* @internal
*/
final class UserRbacSitesApiTest extends AbstractApiTestCase
{
public function testAdminCanAssignSitesToUser(): void
{
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
self::assertNotNull($saintJean);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
// Verification cote base.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertCount(1, $reloaded->getSites());
self::assertSame('Saint-Jean', $reloaded->getSites()->first()->getName());
// Restauration pour ne pas polluer les autres tests.
$this->restoreAliceSites();
}
public function testRemovingCurrentSiteResetsCurrentSiteToNullThenAutoSelectsFirst(): void
{
// alice a actuellement {Chatellerault}, currentSite=Chatellerault.
// On lui attribue {Saint-Jean} : Chatellerault disparait → currentSite
// devrait temporairement etre null, PUIS auto-select Saint-Jean (seul
// site restant).
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Saint-Jean', $reloaded->getCurrentSite()->getName());
$this->restoreAliceSites();
}
public function testEmptySitesPayloadResetsCurrentSiteToNull(): void
{
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => [],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertCount(0, $reloaded->getSites());
self::assertNull($reloaded->getCurrentSite());
$this->restoreAliceSites();
}
public function testCurrentSiteFieldInRbacPayloadIsSilentlyIgnored(): void
{
// Garde structurelle : `currentSite` n'est pas dans le groupe
// user:rbac:write. Un client malveillant qui essaierait de set un
// currentSite arbitraire via /rbac doit etre silencieusement
// ignore (le seul flux autorise est PATCH /me/current-site).
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'currentSite' => '/api/sites/'.$pommevic->getId(),
],
]);
self::assertResponseIsSuccessful();
// alice n'a Pommevic ni dans ses sites ni en currentSite (le champ
// a ete ignore par le denormalizer). Son currentSite reste son
// Chatellerault d'origine.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void
{
// Garde structurelle : si le payload /rbac ne contient pas le champ
// `sites`, ensureCurrentSiteConsistency ne doit pas auto-modifier
// le currentSite (alice avait deja Chatellerault). Un PATCH qui
// change uniquement isAdmin ou roles ne doit pas remuer la
// configuration site de l'user.
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'isAdmin' => false,
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
/**
* Remet alice dans l'etat des fixtures : un seul site Chatellerault,
* currentSite Chatellerault. Evite la pollution inter-tests.
*/
private function restoreAliceSites(): void
{
$em = $this->getEm();
$chatellerault = $em->getRepository(Site::class)->findOneBy(['name' => 'Chatellerault']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
// Reset complet des sites
foreach ($alice->getSites() as $existing) {
$alice->removeSite($existing);
}
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$em->flush();
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels de l'endpoint PATCH /api/me/current-site (switch).
*
* Fixtures utilisees :
* - alice : rattachee a Chatellerault uniquement (currentSite = Chatellerault).
* - admin : rattache aux 3 sites.
* - bob : rattache a Saint-Jean uniquement.
*
* @internal
*/
final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
{
public function testUserCanSwitchToAuthorizedSite(): void
{
// admin a les 3 sites. On le bascule de Chatellerault vers Pommevic.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('Pommevic', $data['currentSite']['name']);
}
public function testUserCannotSwitchToUnauthorizedSite(): void
{
// alice n'a que Chatellerault. Tenter Pommevic → 403.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseStatusCodeSame(403);
}
public function testSwitchWithMissingSiteFieldReturns400(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [],
]);
self::assertResponseStatusCodeSame(400);
}
public function testAnonymousUserCannotSwitch(): void
{
$client = self::createClient();
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/1'],
]);
self::assertResponseStatusCodeSame(401);
}
public function testSwitchWithNonExistentSiteIriReturnsErrorStatus(): void
{
// IRI vers un site qui n'existe pas en base : API Platform leve un
// 400 Bad Request a la denormalisation (l'IriConverter ne peut pas
// resoudre l'IRI). On grave le code de retour reel pour eviter
// qu'une regression silencieuse passe inapercue.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/999999'],
]);
self::assertResponseStatusCodeSame(400);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests d'exposition des sites autorises et du site courant dans /api/me.
*
* Regression-guard du contrat avec le front (ticket 3) : `sites` doit etre
* une liste d'objets Site complets (pas des IRIs), et `currentSite` doit
* etre un objet ou null. Les clients front consomment directement ces
* champs pour alimenter le SiteSelector et le store auth.
*
* @internal
*/
final class MeEndpointSitesTest extends AbstractApiTestCase
{
public function testMeExposesSitesAsObjects(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('sites', $data);
self::assertIsArray($data['sites']);
self::assertCount(1, $data['sites']);
$firstSite = $data['sites'][0];
self::assertIsArray($firstSite, 'Un site doit etre serialise en objet, pas en IRI string.');
self::assertArrayHasKey('id', $firstSite);
self::assertArrayHasKey('name', $firstSite);
self::assertArrayHasKey('street', $firstSite);
self::assertArrayHasKey('city', $firstSite);
self::assertArrayHasKey('color', $firstSite);
// Le getter computed est expose en lecture pour eviter au front
// de redupliquer la logique de concatenation.
self::assertArrayHasKey('fullAddress', $firstSite);
self::assertSame('Chatellerault', $firstSite['name']);
// Garde anti-cycle (cf. Site::$users sans Groups, ticket 2 spec
// section 12 risque 6) : la collection inverse ne doit JAMAIS etre
// serialisee dans /api/me sous peine de boucle infinie
// User → sites → users → sites → ...
self::assertArrayNotHasKey(
'users',
$firstSite,
'Site.users ne doit JAMAIS etre serialise dans /api/me (cycle infini).',
);
}
public function testMeExposesCurrentSiteAsObject(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('currentSite', $data);
self::assertIsArray($data['currentSite'], 'currentSite doit etre un objet, pas une IRI.');
self::assertSame('Chatellerault', $data['currentSite']['name']);
}
public function testAdminHasAllThreeSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/me');
$data = $response->toArray();
self::assertCount(3, $data['sites']);
$names = array_column($data['sites'], 'name');
sort($names);
self::assertSame(['Chatellerault', 'Pommevic', 'Saint-Jean'], $names);
}
public function testUserWithoutSitesHasEmptyArrayAndNullCurrent(): void
{
// Creer un user jetable sans rattachement a un site.
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'orphan_'.$suffix;
$hasher = self::getContainer()->get('security.user_password_hasher');
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, 'testpass'));
$em->persist($user);
$em->flush();
try {
$client = $this->authenticatedClient($username, 'testpass');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame([], $data['sites']);
self::assertNull($data['currentSite']);
} finally {
$em = $this->getEm();
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => $username]);
if (null !== $reloaded) {
$em->remove($reloaded);
$em->flush();
}
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels CRUD /api/sites avec matrices RBAC.
*
* Strategie : les 3 sites fixtures (Chatellerault, Saint-Jean, Pommevic)
* sont presents a chaque test. On nettoie les sites crees par les tests
* via un prefixe `Test-` en setUp + tearDown.
*
* @internal
*/
final class SiteApiTest extends AbstractApiTestCase
{
private const TEST_NAME_PREFIX = 'Test-';
protected function setUp(): void
{
parent::setUp();
$this->cleanupTestSites();
}
protected function tearDown(): void
{
$this->cleanupTestSites();
parent::tearDown();
}
public function testAdminCanListSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testUserWithSitesViewCanListSites(): void
{
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
}
public function testUserWithoutPermissionGetsForbidden(): void
{
// alice a la permission via son role "user" ? Non : le role user par
// defaut n'a aucune permission. Elle ne peut donc pas lister.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(403);
}
public function testUnauthenticatedGetCollectionReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanCreateSite(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-New-Site',
'street' => '1 rue du Test',
'complement' => null,
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#AABBCC',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertSame('Test-New-Site', $data['name']);
self::assertSame('#AABBCC', $data['color']);
}
public function testAdminCanPatchSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Patch-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/sites/'.$site->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['color' => '#FF0000'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('#FF0000', $data['color']);
}
public function testAdminCanDeleteSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Delete-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
$em->clear();
self::assertNull($em->getRepository(Site::class)->find($siteId));
}
public function testUserWithViewButNotManageCannotDelete(): void
{
$em = $this->getEm();
$site = new Site('Test-Protected', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('DELETE', '/api/sites/'.$site->getId());
self::assertResponseStatusCodeSame(403);
}
public function testCreateSiteWithDuplicateNameReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Chatellerault',
'street' => 'Autre rue',
'postalCode' => '75001',
'city' => 'Autre ville',
'color' => '#FF0000',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteWithInvalidColorReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-Color',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => 'red',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteIgnoresFullAddressInPayload(): void
{
// Garde structurelle : `fullAddress` est un getter computed cote
// backend (Site::getFullAddress, groupe site:read uniquement). Si un
// client envoie ce champ en POST, API Platform doit l'ignorer
// silencieusement car il n'est pas dans le groupe site:write. On
// grave ce comportement pour qu'un futur dev qui ajouterait un
// setter casse ce test au lieu de casser l'invariant en silence.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-FullAddress-Ignored',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#000000',
'fullAddress' => 'Adresse arbitraire envoyee par le client',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// Le getter computed prevaut sur ce qu'envoie le client : street
// determine la 1re ligne, jamais la valeur "Adresse arbitraire...".
self::assertSame("1 rue Test\n86000 Poitiers", $data['fullAddress']);
}
public function testCreateSiteWithInvalidPostalCodeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-CP',
'street' => '1 rue Test',
'postalCode' => '123',
'city' => 'Poitiers',
'color' => '#000000',
],
]);
self::assertResponseStatusCodeSame(422);
}
private function cleanupTestSites(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Site::class.' s WHERE s.name LIKE :prefix')
->setParameter('prefix', self::TEST_NAME_PREFIX.'%')
->execute()
;
$em->clear();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests de cascade DB a la suppression d'un site.
*
* Verifie les deux comportements attendus :
* - `user_site` a `ON DELETE CASCADE` : les rattachements sont supprimes ;
* - `user.current_site_id` a `ON DELETE SET NULL` : les users pointant sur
* le site supprime voient leur `currentSite` repasser a NULL.
*
* @internal
*/
final class SiteCascadeTest extends AbstractApiTestCase
{
public function testDeletingSitePurgesUserSiteRows(): void
{
// Creer un site jetable et rattacher alice dessus.
$em = $this->getEm();
$site = new Site('Test-Cascade-Purge', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$alice->addSite($site);
$em->flush();
$em->clear();
// Verifie presence du rattachement M2M via SQL direct (l'EM est cleared).
$connection = $this->getEm()->getConnection();
$before = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(1, $before);
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// L'entree user_site doit avoir disparu via ON DELETE CASCADE.
$after = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(0, $after, 'Les rattachements user_site doivent etre purges en cascade.');
}
public function testDeletingSiteSetsCurrentSiteToNullOnReferencingUsers(): void
{
$em = $this->getEm();
$site = new Site('Test-Cascade-Current', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$aliceId = $alice->getId();
$alice->addSite($site);
$alice->setCurrentSite($site);
$em->flush();
$em->clear();
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// currentSite d'alice doit etre passe a NULL via ON DELETE SET NULL.
$em = $this->getEm();
$em->clear();
$reload = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reload);
self::assertNull(
$reload->getCurrentSite(),
'currentSite doit etre NULL apres suppression du site reference.',
);
}
}

View File

@@ -10,9 +10,9 @@ use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Tests unitaires de comportement de l'entite Site : etat initial, setters
* et gestion des timestamps. Les contraintes de validation (regex, unicite)
* sont couvertes par SiteValidationTest.
* Tests unitaires de comportement de l'entite Site : etat initial, setters,
* gestion des timestamps et getter d'adresse complete. Les contraintes de
* validation (regex, unicite) sont couvertes par SiteValidationTest.
*
* @internal
*/
@@ -21,26 +21,28 @@ final class SiteTest extends TestCase
public function testConstructorInitialState(): void
{
$site = new Site(
'Chatellerault',
'Chatellerault',
'86100',
'#056CF2',
"1 avenue de l'Europe\n86100 Chatellerault",
name: 'Chatellerault',
street: "1 avenue de l'Europe",
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#056CF2',
);
self::assertNull($site->getId());
self::assertSame('Chatellerault', $site->getName());
self::assertSame('Chatellerault', $site->getCity());
self::assertSame("1 avenue de l'Europe", $site->getStreet());
self::assertNull($site->getComplement());
self::assertSame('86100', $site->getPostalCode());
self::assertSame('Chatellerault', $site->getCity());
self::assertSame('#056CF2', $site->getColor());
self::assertStringContainsString('Chatellerault', $site->getFullAddress());
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
}
public function testCreatedAtAndUpdatedAtAreInitiallyEqual(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
// A la creation, les deux timestamps sont seedes avec la meme valeur
// pour garantir updated_at >= created_at au niveau base.
@@ -49,7 +51,7 @@ final class SiteTest extends TestCase
public function testOnPreUpdateAdvancesUpdatedAtOnly(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
$originalCreatedAt = $site->getCreatedAt();
// On force updatedAt a une valeur strictement anterieure via reflection
@@ -69,18 +71,63 @@ final class SiteTest extends TestCase
public function testSettersMutateFields(): void
{
$site = new Site('Old', 'OldCity', '12345', '#000000', 'Old Addr');
$site = new Site('Old', 'Old Street', null, '12345', 'OldCity', '#000000');
$site->setName('New');
$site->setCity('NewCity');
$site->setStreet('New Street');
$site->setComplement('Bat A');
$site->setPostalCode('67890');
$site->setCity('NewCity');
$site->setColor('#ABCDEF');
$site->setFullAddress('New Addr');
self::assertSame('New', $site->getName());
self::assertSame('NewCity', $site->getCity());
self::assertSame('New Street', $site->getStreet());
self::assertSame('Bat A', $site->getComplement());
self::assertSame('67890', $site->getPostalCode());
self::assertSame('NewCity', $site->getCity());
self::assertSame('#ABCDEF', $site->getColor());
self::assertSame('New Addr', $site->getFullAddress());
}
public function testFullAddressGetterWithoutComplement(): void
{
$site = new Site(
name: 'Site1',
street: '1 avenue de l\'Europe',
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#000000',
);
self::assertSame(
"1 avenue de l'Europe\n86100 Chatellerault",
$site->getFullAddress(),
);
}
public function testFullAddressGetterWithComplement(): void
{
$site = new Site(
name: 'Site2',
street: '12 route de Poitiers',
complement: 'Batiment B',
postalCode: '86330',
city: 'Saint-Jean-de-Sauves',
color: '#000000',
);
self::assertSame(
"12 route de Poitiers\nBatiment B\n86330 Saint-Jean-de-Sauves",
$site->getFullAddress(),
);
}
public function testFullAddressGetterIgnoresEmptyComplement(): void
{
// Garde defensive : un complement vide ou whitespace-only ne doit
// pas creer une ligne vide visuellement disgracieuse.
$site = new Site('S', 'Rue', ' ', '12345', 'Ville', '#000000');
self::assertSame("Rue\n12345 Ville", $site->getFullAddress());
}
}

View File

@@ -50,9 +50,15 @@ final class SiteValidationTest extends KernelTestCase
public function testValidSitePassesValidation(): void
{
// Reutilise un nom deja present en fixtures (Chatellerault) impliquerait
// une collision UniqueEntity. On prend donc un nom dedie aux tests.
$site = new Site('Test-Valid-'.uniqid('', true), 'Poitiers', '86000', '#056CF2', 'Adresse valide');
$site = $this->makeSite();
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
}
public function testValidSiteWithComplementPassesValidation(): void
{
$site = $this->makeSite(complement: 'Batiment C');
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
@@ -61,8 +67,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('invalidColorProvider')]
public function testColorMustBeHexRrggbb(string $color): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
@@ -91,8 +96,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('validColorProvider')]
public function testValidColorsAreAccepted(string $color): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
@@ -117,8 +121,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('invalidPostalCodeProvider')]
public function testPostalCodeMustMatchFrFormat(string $postalCode): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
@@ -145,8 +148,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('validPostalCodeProvider')]
public function testValidPostalCodesAreAccepted(string $postalCode): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
@@ -168,8 +170,15 @@ final class SiteValidationTest extends KernelTestCase
public function testBlankNameIsRejected(): void
{
$site = new Site('', 'Y', '12345', '#000000', 'Addr');
$site = $this->makeSite(name: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankStreetIsRejected(): void
{
$site = $this->makeSite(street: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -177,17 +186,7 @@ final class SiteValidationTest extends KernelTestCase
public function testBlankCityIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), '', '12345', '#000000', 'Addr');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankFullAddressIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', '#000000', '');
$site = $this->makeSite(city: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -195,8 +194,7 @@ final class SiteValidationTest extends KernelTestCase
public function testNameLongerThan100CharsIsRejected(): void
{
$site = new Site(str_repeat('a', 101), 'Y', '12345', '#000000', 'Addr');
$site = $this->makeSite(name: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -204,8 +202,23 @@ final class SiteValidationTest extends KernelTestCase
public function testCityLongerThan100CharsIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), str_repeat('a', 101), '12345', '#000000', 'Addr');
$site = $this->makeSite(city: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testStreetLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(street: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testComplementLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(complement: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -223,16 +236,13 @@ final class SiteValidationTest extends KernelTestCase
*/
public function testDuplicateNameIsRejected(): void
{
// Nom unique par execution pour eviter toute collision avec les
// fixtures (Chatellerault, Saint-Jean, Pommevic) ou des tests
// paralleles.
$name = 'Test-Duplicate-'.uniqid('', true);
$original = new Site($name, 'Poitiers', '86000', '#056CF2', 'Adresse originale');
$original = $this->makeSite(name: $name);
$this->em->persist($original);
$this->em->flush();
try {
$duplicate = new Site($name, 'Autre ville', '75001', '#FF0000', 'Autre adresse');
$duplicate = $this->makeSite(name: $name, city: 'Autre');
$violations = $this->validator->validate($duplicate);
self::assertGreaterThan(0, $violations->count(), 'Un site homonyme doit lever au moins une violation.');
@@ -256,4 +266,26 @@ final class SiteValidationTest extends KernelTestCase
$this->em->flush();
}
}
/**
* Helper : construit un Site valide avec un nom unique, sur lequel on
* peut superposer un seul champ invalide pour tester une contrainte.
*/
private function makeSite(
?string $name = null,
string $street = '1 rue Test',
?string $complement = null,
string $postalCode = '12345',
string $city = 'Poitiers',
string $color = '#000000',
): Site {
return new Site(
$name ?? 'Test-'.uniqid('', true),
$street,
$complement,
$postalCode,
$city,
$color,
);
}
}