feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)
Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage), relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable, ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select premier site si null + sites non vide. Page /admin/sites (DataTable + drawer creation/edition + modale suppression). UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee dans la table /admin/users (liste des noms separes par virgule). Sidebar entree Sites (module: sites, permission: sites.view). Refactor adresse : split full_address en street + complement (nullable) + getter computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses (Chatellerault/Fontenet/Pommevic). Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la collection inverse en memoire. User::switchCurrentSite() porte la garde domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable. Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase. Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension /rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle Site.users). 11 tests Vitest sur la validation hex couleur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,13 @@ return [
|
|||||||
'module' => 'core',
|
'module' => 'core',
|
||||||
'permission' => 'core.users.view',
|
'permission' => 'core.users.view',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.core.sites',
|
||||||
|
'to' => '/admin/sites',
|
||||||
|
'icon' => 'mdi:domain',
|
||||||
|
'module' => 'sites',
|
||||||
|
'permission' => 'sites.view',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.general.logout',
|
'label' => 'sidebar.general.logout',
|
||||||
'to' => '/logout',
|
'to' => '/logout',
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
},
|
},
|
||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs"
|
"users": "Utilisateurs",
|
||||||
|
"sites": "Sites"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -54,6 +55,9 @@
|
|||||||
"put": "Erreur lors de la mise a jour",
|
"put": "Erreur lors de la mise a jour",
|
||||||
"patch": "Erreur lors de la modification",
|
"patch": "Erreur lors de la modification",
|
||||||
"delete": "Erreur lors de la suppression"
|
"delete": "Erreur lors de la suppression"
|
||||||
|
},
|
||||||
|
"sites": {
|
||||||
|
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
@@ -102,7 +106,8 @@
|
|||||||
"username": "Nom d'utilisateur",
|
"username": "Nom d'utilisateur",
|
||||||
"admin": "Administrateur",
|
"admin": "Administrateur",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"directPermissions": "Permissions directes"
|
"directPermissions": "Permissions directes",
|
||||||
|
"sites": "Sites"
|
||||||
},
|
},
|
||||||
"drawer": {
|
"drawer": {
|
||||||
"title": "Permissions de {username}",
|
"title": "Permissions de {username}",
|
||||||
@@ -110,6 +115,7 @@
|
|||||||
"adminToggle": "Administrateur (bypass total)",
|
"adminToggle": "Administrateur (bypass total)",
|
||||||
"rolesSection": "Rôles",
|
"rolesSection": "Rôles",
|
||||||
"directPermissionsSection": "Permissions directes",
|
"directPermissionsSection": "Permissions directes",
|
||||||
|
"sitesSection": "Sites autorisés",
|
||||||
"summarySection": "Résumé des permissions effectives",
|
"summarySection": "Résumé des permissions effectives",
|
||||||
"noEffectivePermissions": "Aucune permission effective",
|
"noEffectivePermissions": "Aucune permission effective",
|
||||||
"sourceRole": "via {role}",
|
"sourceRole": "via {role}",
|
||||||
@@ -119,6 +125,39 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"updated": "Permissions mises à jour avec succès"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Section Resume permissions effectives -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||||
@@ -92,6 +113,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
||||||
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
interface PermissionModule {
|
||||||
module: string
|
module: string
|
||||||
@@ -115,10 +137,12 @@ const emit = defineEmits<{
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const allRoles = ref<Role[]>([])
|
const allRoles = ref<Role[]>([])
|
||||||
const allPermissions = ref<Permission[]>([])
|
const allPermissions = ref<Permission[]>([])
|
||||||
|
const allSites = ref<Site[]>([])
|
||||||
|
|
||||||
const form = ref({ isAdmin: false })
|
const form = ref({ isAdmin: false })
|
||||||
const selectedRoleIds = ref(new Set<number>())
|
const selectedRoleIds = ref(new Set<number>())
|
||||||
const selectedDirectPermissionIds = ref(new Set<number>())
|
const selectedDirectPermissionIds = ref(new Set<number>())
|
||||||
|
const selectedSiteIds = ref(new Set<number>())
|
||||||
|
|
||||||
// Detecter l'auto-edition
|
// Detecter l'auto-edition
|
||||||
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
|
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))
|
.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() {
|
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: Role[] }>('/roles', {}, { toast: false }),
|
||||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { 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
|
allRoles.value = rolesData.member
|
||||||
allPermissions.value = permsData.member
|
allPermissions.value = permsData.member
|
||||||
|
allSites.value = sitesData.member
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remplir le formulaire quand le user change
|
// Remplir le formulaire quand le user change
|
||||||
@@ -198,10 +225,12 @@ watch(() => props.user, (user) => {
|
|||||||
form.value.isAdmin = user.isAdmin
|
form.value.isAdmin = user.isAdmin
|
||||||
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
||||||
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
||||||
|
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
|
||||||
} else {
|
} else {
|
||||||
form.value.isAdmin = false
|
form.value.isAdmin = false
|
||||||
selectedRoleIds.value = new Set()
|
selectedRoleIds.value = new Set()
|
||||||
selectedDirectPermissionIds.value = new Set()
|
selectedDirectPermissionIds.value = new Set()
|
||||||
|
selectedSiteIds.value = new Set()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -235,6 +264,13 @@ function handleToggleAll(module: string, selected: boolean) {
|
|||||||
selectedDirectPermissionIds.value = ids
|
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() {
|
async function handleSave() {
|
||||||
if (!props.user) return
|
if (!props.user) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -243,6 +279,7 @@ async function handleSave() {
|
|||||||
isAdmin: form.value.isAdmin,
|
isAdmin: form.value.isAdmin,
|
||||||
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
|
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
|
||||||
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${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'),
|
toastSuccessMessage: t('admin.users.toast.updated'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -48,6 +49,7 @@ useHead({ title: t('admin.users.title') })
|
|||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
const users = ref<UserListItem[]>([])
|
const users = ref<UserListItem[]>([])
|
||||||
|
const sitesById = ref(new Map<number, Site>())
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
@@ -57,8 +59,14 @@ const columns = [
|
|||||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
{ 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(() =>
|
const userItems = computed(() =>
|
||||||
users.value.map(user => ({
|
users.value.map(user => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -66,18 +74,27 @@ const userItems = computed(() =>
|
|||||||
admin: user.isAdmin,
|
admin: user.isAdmin,
|
||||||
roles: user.roles.length,
|
roles: user.roles.length,
|
||||||
directPermissions: user.directPermissions.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() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ member: UserListItem[] }>(
|
// Chargement parallele : les sites alimentent la Map de resolution
|
||||||
'/users',
|
// IRI→name pour la colonne "Sites" de la table.
|
||||||
{},
|
const [usersData, sitesData] = await Promise.all([
|
||||||
{ toast: false },
|
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
|
||||||
)
|
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||||
users.value = data.member
|
])
|
||||||
|
users.value = usersData.member
|
||||||
|
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal 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>
|
||||||
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
185
frontend/modules/sites/components/SiteDrawer.vue
Normal 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>
|
||||||
1
frontend/modules/sites/nuxt.config.ts
Normal file
1
frontend/modules/sites/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
164
frontend/modules/sites/pages/admin/sites.vue
Normal file
164
frontend/modules/sites/pages/admin/sites.vue
Normal 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()
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSiteSaved() {
|
||||||
|
loadSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSites()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
50
frontend/modules/sites/utils/__tests__/color.test.ts
Normal file
50
frontend/modules/sites/utils/__tests__/color.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
15
frontend/modules/sites/utils/color.ts
Normal file
15
frontend/modules/sites/utils/color.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export interface UserListItem {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
roles: string[]
|
roles: string[]
|
||||||
directPermissions: string[]
|
directPermissions: string[]
|
||||||
|
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||||
|
sites: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectivePermission {
|
export interface EffectivePermission {
|
||||||
|
|||||||
24
frontend/shared/types/sites.ts
Normal file
24
frontend/shared/types/sites.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface Site {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
street: string
|
||||||
|
complement: string | null
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
color: string
|
||||||
|
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
|
||||||
|
fullAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
|
||||||
|
* (computed cote backend) ni champs read-only (id, timestamps).
|
||||||
|
*/
|
||||||
|
export interface SiteInput {
|
||||||
|
name: string
|
||||||
|
street: string
|
||||||
|
complement: string | null
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Site } from './sites'
|
||||||
|
|
||||||
export interface UserData {
|
export interface UserData {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
@@ -6,4 +8,8 @@ export interface UserData {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||||
effectivePermissions: string[]
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ final class Version20260417120000 extends AbstractMigration
|
|||||||
// - `postal_code` est limite a 10 caracteres pour laisser marge a
|
// - `postal_code` est limite a 10 caracteres pour laisser marge a
|
||||||
// d'eventuels formats etrangers plus tard, tout en le validant
|
// d'eventuels formats etrangers plus tard, tout en le validant
|
||||||
// strictement en 5 chiffres cote applicatif (format FR).
|
// 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'
|
$this->addSql(<<<'SQL'
|
||||||
CREATE TABLE site (
|
CREATE TABLE site (
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
|||||||
88
migrations/Version20260417150000.php
Normal file
88
migrations/Version20260417150000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
78
migrations/Version20260420130000.php
Normal file
78
migrations/Version20260420130000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
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'])]
|
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||||
private Collection $directPermissions;
|
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')]
|
||||||
|
#[ORM\JoinTable(name: 'user_site')]
|
||||||
|
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
|
||||||
|
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]
|
#[ORM\Column]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
@@ -121,6 +156,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
$this->rbacRoles = new ArrayCollection();
|
$this->rbacRoles = new ArrayCollection();
|
||||||
$this->directPermissions = new ArrayCollection();
|
$this->directPermissions = new ArrayCollection();
|
||||||
|
$this->sites = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -313,4 +349,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
{
|
{
|
||||||
$this->plainPassword = null;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|||||||
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
||||||
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
||||||
* AdminHeadcountGuardInterface.
|
* 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>
|
* @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);
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
$user->setCurrentSite($sites->first() ?: null);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||||
use App\Module\Core\Domain\Security\SystemRoles;
|
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\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
|
* 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
|
* 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
|
* entites managees, ce qui inclut la table role. On re-seede donc les roles
|
||||||
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
||||||
* que le workflow "make db-reset && make fixtures" reste one-shot.
|
* 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(
|
public function __construct(
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
private readonly RoleRepositoryInterface $roleRepository,
|
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
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
$adminRole = $this->ensureSystemRole(
|
$adminRole = $this->ensureSystemRole(
|
||||||
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
|
|||||||
'Role de base sans permission specifique',
|
'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 = new User();
|
||||||
$admin->setUsername('admin');
|
$admin->setUsername('admin');
|
||||||
$admin->setIsAdmin(true);
|
$admin->setIsAdmin(true);
|
||||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||||
$admin->addRbacRole($adminRole);
|
$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);
|
$manager->persist($admin);
|
||||||
|
|
||||||
$alice = new User();
|
$alice = new User();
|
||||||
$alice->setUsername('alice');
|
$alice->setUsername('alice');
|
||||||
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
||||||
$alice->addRbacRole($userRole);
|
$alice->addRbacRole($userRole);
|
||||||
|
// Alice : un seul site, site courant = ce site.
|
||||||
|
$alice->addSite($chatellerault);
|
||||||
|
$alice->setCurrentSite($chatellerault);
|
||||||
$manager->persist($alice);
|
$manager->persist($alice);
|
||||||
|
|
||||||
$bob = new User();
|
$bob = new User();
|
||||||
$bob->setUsername('bob');
|
$bob->setUsername('bob');
|
||||||
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
||||||
$bob->addRbacRole($userRole);
|
$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->persist($bob);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
|
|||||||
|
|
||||||
return $role;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,63 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Sites\Domain\Entity;
|
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 App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
||||||
*
|
*
|
||||||
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
|
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
||||||
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
|
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
||||||
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
|
* entre champs duplique. La methode `getFullAddress()` fournit la version
|
||||||
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
|
* concatenee multi-lignes pour les usages d'affichage.
|
||||||
* est creee meme si le module est desactive (voir migration dediee).
|
*
|
||||||
|
* 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')",
|
||||||
|
),
|
||||||
|
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\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||||
#[ORM\Table(name: 'site')]
|
#[ORM\Table(name: 'site')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||||
@@ -30,17 +71,27 @@ class Site
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['site:read', 'me:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
|
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
|
||||||
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
|
#[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;
|
private string $name;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
// Premiere ligne d'adresse : numero + voie. Requise.
|
||||||
#[Assert\NotBlank(message: 'La ville du site est requise.')]
|
#[ORM\Column(length: 255)]
|
||||||
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
|
#[Assert\NotBlank(message: 'La rue est requise.')]
|
||||||
private string $city;
|
#[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
|
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
|
||||||
// colonnes en minuscules dans le SQL brut). Le format est contraint au
|
// colonnes en minuscules dans le SQL brut). Le format est contraint au
|
||||||
@@ -52,50 +103,71 @@ class Site
|
|||||||
pattern: '/^\d{5}$/',
|
pattern: '/^\d{5}$/',
|
||||||
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
|
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
|
||||||
)]
|
)]
|
||||||
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||||
private string $postalCode;
|
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
|
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
|
||||||
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
|
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
|
||||||
// distinguer les sites d'un coup d'oeil.
|
// les sites d'un coup d'oeil.
|
||||||
#[ORM\Column(length: 7)]
|
#[ORM\Column(length: 7)]
|
||||||
#[Assert\NotBlank(message: 'La couleur est requise.')]
|
#[Assert\NotBlank(message: 'La couleur est requise.')]
|
||||||
#[Assert\Regex(
|
#[Assert\Regex(
|
||||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||||
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
|
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
|
||||||
)]
|
)]
|
||||||
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||||
private string $color;
|
private string $color;
|
||||||
|
|
||||||
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
|
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
|
||||||
// lignes (voie + complement + mention particuliere). Borne haute a 500
|
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
|
||||||
// caracteres : une adresse francaise complete tient tres largement dans
|
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
|
||||||
// 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;
|
|
||||||
|
|
||||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
#[Groups(['site:read'])]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
#[Groups(['site:read'])]
|
||||||
private DateTimeImmutable $updatedAt;
|
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(
|
public function __construct(
|
||||||
string $name,
|
string $name,
|
||||||
string $city,
|
string $street,
|
||||||
|
?string $complement,
|
||||||
string $postalCode,
|
string $postalCode,
|
||||||
|
string $city,
|
||||||
string $color,
|
string $color,
|
||||||
string $fullAddress,
|
|
||||||
) {
|
) {
|
||||||
$this->name = $name;
|
$this->name = $name;
|
||||||
$this->city = $city;
|
$this->street = $street;
|
||||||
$this->postalCode = $postalCode;
|
$this->complement = $complement;
|
||||||
$this->color = $color;
|
$this->postalCode = $postalCode;
|
||||||
$this->fullAddress = $fullAddress;
|
$this->city = $city;
|
||||||
$now = new DateTimeImmutable();
|
$this->color = $color;
|
||||||
$this->createdAt = $now;
|
$now = new DateTimeImmutable();
|
||||||
$this->updatedAt = $now;
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
$this->users = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,14 +197,26 @@ class Site
|
|||||||
return $this;
|
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;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -149,6 +233,18 @@ class Site
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getCity(): string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCity(string $city): static
|
||||||
|
{
|
||||||
|
$this->city = $city;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getColor(): string
|
public function getColor(): string
|
||||||
{
|
{
|
||||||
return $this->color;
|
return $this->color;
|
||||||
@@ -161,16 +257,26 @@ class Site
|
|||||||
return $this;
|
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
|
public function getFullAddress(): string
|
||||||
{
|
{
|
||||||
return $this->fullAddress;
|
$lines = [$this->street];
|
||||||
}
|
|
||||||
|
|
||||||
public function setFullAddress(string $fullAddress): static
|
if (null !== $this->complement && '' !== trim($this->complement)) {
|
||||||
{
|
$lines[] = $this->complement;
|
||||||
$this->fullAddress = $fullAddress;
|
}
|
||||||
|
|
||||||
return $this;
|
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
@@ -182,4 +288,39 @@ class Site
|
|||||||
{
|
{
|
||||||
return $this->updatedAt;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,38 +40,43 @@ class SitesFixtures extends Fixture
|
|||||||
$this->ensureSite(
|
$this->ensureSite(
|
||||||
$manager,
|
$manager,
|
||||||
name: 'Chatellerault',
|
name: 'Chatellerault',
|
||||||
city: 'Chatellerault',
|
street: "14 All. d'Argenson",
|
||||||
|
complement: null,
|
||||||
postalCode: '86100',
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
color: '#056CF2',
|
color: '#056CF2',
|
||||||
fullAddress: "1 avenue de l'Europe\n86100 Chatellerault",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Saint-Jean : vert emeraude pour contraster avec le bleu 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(
|
$this->ensureSite(
|
||||||
$manager,
|
$manager,
|
||||||
name: 'Saint-Jean',
|
name: 'Saint-Jean',
|
||||||
city: 'Saint-Jean-de-Sauves',
|
street: 'Z i',
|
||||||
postalCode: '86330',
|
complement: null,
|
||||||
|
postalCode: '17400',
|
||||||
|
city: 'Fontenet',
|
||||||
color: '#10B981',
|
color: '#10B981',
|
||||||
fullAddress: "12 route de Poitiers\n86330 Saint-Jean-de-Sauves",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pommevic : ambre pour une troisieme teinte nettement distincte.
|
// Pommevic : ambre pour une troisieme teinte nettement distincte.
|
||||||
$this->ensureSite(
|
$this->ensureSite(
|
||||||
$manager,
|
$manager,
|
||||||
name: 'Pommevic',
|
name: 'Pommevic',
|
||||||
city: 'Pommevic',
|
street: '1 Av. Jean Duquesne',
|
||||||
|
complement: null,
|
||||||
postalCode: '82400',
|
postalCode: '82400',
|
||||||
|
city: 'Pommevic',
|
||||||
color: '#F59E0B',
|
color: '#F59E0B',
|
||||||
fullAddress: "5 chemin des Peupliers\n82400 Pommevic",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cree le site s'il n'existe pas encore, sinon re-aligne ville, code
|
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
|
||||||
* postal, couleur et adresse sur les valeurs de reference.
|
* 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
|
* 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
|
* donc pas resynchronise. Consequence : renommer un site dans la
|
||||||
@@ -81,24 +86,26 @@ class SitesFixtures extends Fixture
|
|||||||
private function ensureSite(
|
private function ensureSite(
|
||||||
ObjectManager $manager,
|
ObjectManager $manager,
|
||||||
string $name,
|
string $name,
|
||||||
string $city,
|
string $street,
|
||||||
|
?string $complement,
|
||||||
string $postalCode,
|
string $postalCode,
|
||||||
|
string $city,
|
||||||
string $color,
|
string $color,
|
||||||
string $fullAddress,
|
|
||||||
): Site {
|
): Site {
|
||||||
$site = $this->siteRepository->findByName($name);
|
$site = $this->siteRepository->findByName($name);
|
||||||
|
|
||||||
if (null === $site) {
|
if (null === $site) {
|
||||||
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
|
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
|
||||||
$manager->persist($site);
|
$manager->persist($site);
|
||||||
|
|
||||||
return $site;
|
return $site;
|
||||||
}
|
}
|
||||||
|
|
||||||
$site->setCity($city);
|
$site->setStreet($street);
|
||||||
|
$site->setComplement($complement);
|
||||||
$site->setPostalCode($postalCode);
|
$site->setPostalCode($postalCode);
|
||||||
|
$site->setCity($city);
|
||||||
$site->setColor($color);
|
$site->setColor($color);
|
||||||
$site->setFullAddress($fullAddress);
|
|
||||||
|
|
||||||
return $site;
|
return $site;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,4 +130,34 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
|
|
||||||
return ['username' => $username, 'password' => $password];
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
189
tests/Module/Core/Api/UserRbacSitesApiTest.php
Normal file
189
tests/Module/Core/Api/UserRbacSitesApiTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php
Normal file
92
tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/Module/Sites/Api/MeEndpointSitesTest.php
Normal file
116
tests/Module/Sites/Api/MeEndpointSitesTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
tests/Module/Sites/Api/SiteApiTest.php
Normal file
235
tests/Module/Sites/Api/SiteApiTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/Module/Sites/Api/SiteCascadeTest.php
Normal file
90
tests/Module/Sites/Api/SiteCascadeTest.php
Normal 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires de comportement de l'entite Site : etat initial, setters
|
* Tests unitaires de comportement de l'entite Site : etat initial, setters,
|
||||||
* et gestion des timestamps. Les contraintes de validation (regex, unicite)
|
* gestion des timestamps et getter d'adresse complete. Les contraintes de
|
||||||
* sont couvertes par SiteValidationTest.
|
* validation (regex, unicite) sont couvertes par SiteValidationTest.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -21,26 +21,28 @@ final class SiteTest extends TestCase
|
|||||||
public function testConstructorInitialState(): void
|
public function testConstructorInitialState(): void
|
||||||
{
|
{
|
||||||
$site = new Site(
|
$site = new Site(
|
||||||
'Chatellerault',
|
name: 'Chatellerault',
|
||||||
'Chatellerault',
|
street: "1 avenue de l'Europe",
|
||||||
'86100',
|
complement: null,
|
||||||
'#056CF2',
|
postalCode: '86100',
|
||||||
"1 avenue de l'Europe\n86100 Chatellerault",
|
city: 'Chatellerault',
|
||||||
|
color: '#056CF2',
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertNull($site->getId());
|
self::assertNull($site->getId());
|
||||||
self::assertSame('Chatellerault', $site->getName());
|
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('86100', $site->getPostalCode());
|
||||||
|
self::assertSame('Chatellerault', $site->getCity());
|
||||||
self::assertSame('#056CF2', $site->getColor());
|
self::assertSame('#056CF2', $site->getColor());
|
||||||
self::assertStringContainsString('Chatellerault', $site->getFullAddress());
|
|
||||||
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
|
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
|
||||||
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
|
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreatedAtAndUpdatedAtAreInitiallyEqual(): void
|
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
|
// A la creation, les deux timestamps sont seedes avec la meme valeur
|
||||||
// pour garantir updated_at >= created_at au niveau base.
|
// pour garantir updated_at >= created_at au niveau base.
|
||||||
@@ -49,7 +51,7 @@ final class SiteTest extends TestCase
|
|||||||
|
|
||||||
public function testOnPreUpdateAdvancesUpdatedAtOnly(): void
|
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();
|
$originalCreatedAt = $site->getCreatedAt();
|
||||||
|
|
||||||
// On force updatedAt a une valeur strictement anterieure via reflection
|
// On force updatedAt a une valeur strictement anterieure via reflection
|
||||||
@@ -69,18 +71,63 @@ final class SiteTest extends TestCase
|
|||||||
|
|
||||||
public function testSettersMutateFields(): void
|
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->setName('New');
|
||||||
$site->setCity('NewCity');
|
$site->setStreet('New Street');
|
||||||
|
$site->setComplement('Bat A');
|
||||||
$site->setPostalCode('67890');
|
$site->setPostalCode('67890');
|
||||||
|
$site->setCity('NewCity');
|
||||||
$site->setColor('#ABCDEF');
|
$site->setColor('#ABCDEF');
|
||||||
$site->setFullAddress('New Addr');
|
|
||||||
|
|
||||||
self::assertSame('New', $site->getName());
|
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('67890', $site->getPostalCode());
|
||||||
|
self::assertSame('NewCity', $site->getCity());
|
||||||
self::assertSame('#ABCDEF', $site->getColor());
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,15 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
|
|
||||||
public function testValidSitePassesValidation(): void
|
public function testValidSitePassesValidation(): void
|
||||||
{
|
{
|
||||||
// Reutilise un nom deja present en fixtures (Chatellerault) impliquerait
|
$site = $this->makeSite();
|
||||||
// une collision UniqueEntity. On prend donc un nom dedie aux tests.
|
$violations = $this->validator->validate($site);
|
||||||
$site = new Site('Test-Valid-'.uniqid('', true), 'Poitiers', '86000', '#056CF2', 'Adresse valide');
|
|
||||||
|
self::assertCount(0, $violations, (string) $violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidSiteWithComplementPassesValidation(): void
|
||||||
|
{
|
||||||
|
$site = $this->makeSite(complement: 'Batiment C');
|
||||||
$violations = $this->validator->validate($site);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertCount(0, $violations, (string) $violations);
|
self::assertCount(0, $violations, (string) $violations);
|
||||||
@@ -61,8 +67,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
#[DataProvider('invalidColorProvider')]
|
#[DataProvider('invalidColorProvider')]
|
||||||
public function testColorMustBeHexRrggbb(string $color): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
|
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
|
||||||
@@ -91,8 +96,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
#[DataProvider('validColorProvider')]
|
#[DataProvider('validColorProvider')]
|
||||||
public function testValidColorsAreAccepted(string $color): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
|
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
|
||||||
@@ -117,8 +121,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
#[DataProvider('invalidPostalCodeProvider')]
|
#[DataProvider('invalidPostalCodeProvider')]
|
||||||
public function testPostalCodeMustMatchFrFormat(string $postalCode): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
|
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
|
||||||
@@ -145,8 +148,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
#[DataProvider('validPostalCodeProvider')]
|
#[DataProvider('validPostalCodeProvider')]
|
||||||
public function testValidPostalCodesAreAccepted(string $postalCode): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertCount(0, $violations, (string) $violations);
|
self::assertCount(0, $violations, (string) $violations);
|
||||||
@@ -168,8 +170,15 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
|
|
||||||
public function testBlankNameIsRejected(): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count());
|
self::assertGreaterThan(0, $violations->count());
|
||||||
@@ -177,17 +186,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
|
|
||||||
public function testBlankCityIsRejected(): void
|
public function testBlankCityIsRejected(): void
|
||||||
{
|
{
|
||||||
$site = new Site('Test-'.uniqid('', true), '', '12345', '#000000', 'Addr');
|
$site = $this->makeSite(city: '');
|
||||||
|
|
||||||
$violations = $this->validator->validate($site);
|
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlankFullAddressIsRejected(): void
|
|
||||||
{
|
|
||||||
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', '#000000', '');
|
|
||||||
|
|
||||||
$violations = $this->validator->validate($site);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count());
|
self::assertGreaterThan(0, $violations->count());
|
||||||
@@ -195,8 +194,7 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
|
|
||||||
public function testNameLongerThan100CharsIsRejected(): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count());
|
self::assertGreaterThan(0, $violations->count());
|
||||||
@@ -204,8 +202,23 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
|
|
||||||
public function testCityLongerThan100CharsIsRejected(): void
|
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);
|
$violations = $this->validator->validate($site);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count());
|
self::assertGreaterThan(0, $violations->count());
|
||||||
@@ -223,16 +236,13 @@ final class SiteValidationTest extends KernelTestCase
|
|||||||
*/
|
*/
|
||||||
public function testDuplicateNameIsRejected(): void
|
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);
|
$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->persist($original);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$duplicate = new Site($name, 'Autre ville', '75001', '#FF0000', 'Autre adresse');
|
$duplicate = $this->makeSite(name: $name, city: 'Autre');
|
||||||
$violations = $this->validator->validate($duplicate);
|
$violations = $this->validator->validate($duplicate);
|
||||||
|
|
||||||
self::assertGreaterThan(0, $violations->count(), 'Un site homonyme doit lever au moins une violation.');
|
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();
|
$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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user