Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed131ce57 | |||
| a948eed9b6 | |||
| fc78f434d1 | |||
| 53e19d61ac | |||
| ece8146c03 | |||
| 58589e93d0 |
@@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
|||||||
## PostgreSQL
|
## PostgreSQL
|
||||||
|
|
||||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||||
|
|
||||||
|
## Migrations Doctrine
|
||||||
|
|
||||||
|
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||||
|
|
||||||
|
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||||
|
|
||||||
|
**Format de la description** :
|
||||||
|
- En francais
|
||||||
|
- ≤ 200 caracteres
|
||||||
|
- Semantique du champ — contraintes / lien RG si pertinent
|
||||||
|
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||||
|
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||||
|
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||||
|
|
||||||
|
// Cas FK : preciser la cible
|
||||||
|
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||||
|
|
||||||
|
// Cas booleen : preciser le sens et la valeur par defaut
|
||||||
|
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||||
|
|
||||||
|
// Bonus : decrire la table elle-meme
|
||||||
|
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Timestampable/Blamable
|
||||||
|
|
||||||
|
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||||
|
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||||
|
```
|
||||||
|
|
||||||
|
L'implementation du helper applique :
|
||||||
|
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||||
|
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||||
|
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||||
|
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||||
|
|
||||||
|
### Garde-fou architecture
|
||||||
|
|
||||||
|
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||||
|
|
||||||
|
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ jobs:
|
|||||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||||
|
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||||
|
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||||
|
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
|||||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||||
|
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@.claude/rules/architecture.md
|
@.claude/rules/architecture.md
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.50'
|
app.version: '0.1.53'
|
||||||
|
|||||||
@@ -16,29 +16,30 @@
|
|||||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name.value"
|
||||||
:label="t('admin.categories.form.name')"
|
:label="t('admin.categories.form.name')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
:max-length="120"
|
:max-length="120"
|
||||||
:error="errors.name"
|
:error="form.errors.value.name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||||
number (categoryType id) ; conversion en IRI au moment du save. -->
|
number (categoryType id) ; conversion en IRI au moment du save
|
||||||
|
par le composable useCategoryForm. -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="form.categoryTypeId"
|
v-model="form.categoryTypeId.value"
|
||||||
:options="categoryTypeOptions"
|
:options="typeOptions"
|
||||||
:label="t('admin.categories.form.type')"
|
:label="t('admin.categories.form.type')"
|
||||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||||
:error="errors.categoryType"
|
:error="form.errors.value.categoryType"
|
||||||
:disabled="loadingTypes"
|
:disabled="loadingTypes"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||||
erreurs de validation par champ. -->
|
erreurs de validation par champ. -->
|
||||||
<p v-if="errors._global" class="text-sm text-red-600">
|
<p v-if="form.errors.value._global" class="text-sm text-red-600">
|
||||||
{{ errors._global }}
|
{{ form.errors.value._global }}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-[150px]"
|
button-class="w-[150px]"
|
||||||
:disabled="saving || loadingTypes"
|
:disabled="form.submitting.value || loadingTypes"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,12 +75,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||||
|
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||||
|
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -93,56 +96,20 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mode du drawer :
|
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||||
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||||
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||||
* jusqu'a ce que l'utilisateur modifie un champ.
|
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||||
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||||
* champ different de l'original), PATCH au save.
|
* champ different de l'original), PATCH au save.
|
||||||
*
|
|
||||||
* La bascule view → edit est automatique des qu'un champ change (cf. watch
|
|
||||||
* sur form). Le label du header suit le mode courant.
|
|
||||||
*/
|
*/
|
||||||
type DrawerMode = 'create' | 'view' | 'edit'
|
type DrawerMode = 'create' | 'view' | 'edit'
|
||||||
|
|
||||||
const saving = ref(false)
|
|
||||||
const loadingTypes = ref(false)
|
|
||||||
const categoryTypes = ref<CategoryType[]>([])
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
name: '',
|
|
||||||
categoryTypeId: null as number | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Snapshot des valeurs initiales pour detecter le « dirty » (view → edit).
|
|
||||||
const initial = ref({
|
|
||||||
name: '',
|
|
||||||
categoryTypeId: null as number | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Erreurs par champ + erreur transverse globale. Pattern propre pour mapper
|
|
||||||
// les violations 422 sur les MalioInputText / MalioSelect.
|
|
||||||
const errors = ref<{
|
|
||||||
name: string
|
|
||||||
categoryType: string
|
|
||||||
_global: string
|
|
||||||
}>({
|
|
||||||
name: '',
|
|
||||||
categoryType: '',
|
|
||||||
_global: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const isCreateMode = computed(() => props.category === null)
|
const isCreateMode = computed(() => props.category === null)
|
||||||
|
|
||||||
const isDirty = computed(
|
|
||||||
() =>
|
|
||||||
form.value.name !== initial.value.name
|
|
||||||
|| form.value.categoryTypeId !== initial.value.categoryTypeId,
|
|
||||||
)
|
|
||||||
|
|
||||||
const mode = computed<DrawerMode>(() => {
|
const mode = computed<DrawerMode>(() => {
|
||||||
if (isCreateMode.value) return 'create'
|
if (isCreateMode.value) return 'create'
|
||||||
return isDirty.value ? 'edit' : 'view'
|
return form.isDirty.value ? 'edit' : 'view'
|
||||||
})
|
})
|
||||||
|
|
||||||
const headerLabel = computed(() => {
|
const headerLabel = computed(() => {
|
||||||
@@ -164,207 +131,48 @@ const canShowSave = computed(
|
|||||||
() => mode.value === 'create' || mode.value === 'edit',
|
() => mode.value === 'create' || mode.value === 'edit',
|
||||||
)
|
)
|
||||||
|
|
||||||
const categoryTypeOptions = computed(() =>
|
const typeOptions = computed(() =>
|
||||||
categoryTypes.value.map(ct => ({
|
types.value.map(ct => ({
|
||||||
label: ct.label,
|
label: ct.label,
|
||||||
value: ct.id,
|
value: ct.id,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||||
* Charge le referentiel CategoryType. Appele a chaque ouverture du drawer
|
// autre ligne sans fermer le drawer entre-temps).
|
||||||
* (pas seulement au mount) pour rester a jour si un type est ajoute en
|
watch(() => props.category, (cat) => {
|
||||||
* arriere-plan. Volontairement sans toast en cas d'echec : on affiche un
|
form.loadFrom(cat)
|
||||||
* message inline via `errors._global` pour ne pas spammer.
|
}, { immediate: true })
|
||||||
*/
|
|
||||||
async function loadCategoryTypes(): Promise<void> {
|
|
||||||
loadingTypes.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<HydraCollection<CategoryType>>(
|
|
||||||
'/category_types',
|
|
||||||
{ itemsPerPage: 999 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
categoryTypes.value = data.member ?? []
|
|
||||||
} catch {
|
|
||||||
categoryTypes.value = []
|
|
||||||
errors.value._global = t('admin.categories.toast.typesLoadFailed')
|
|
||||||
} finally {
|
|
||||||
loadingTypes.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||||
* Re-initialise le formulaire a partir de la prop `category`. Aussi appele
|
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||||
* a l'ouverture du drawer pour repartir d'un etat propre.
|
// d'optimisation cache au M0, le referentiel est petit).
|
||||||
*/
|
|
||||||
function resetForm(): void {
|
|
||||||
errors.value = { name: '', categoryType: '', _global: '' }
|
|
||||||
if (props.category) {
|
|
||||||
form.value.name = props.category.name
|
|
||||||
form.value.categoryTypeId = props.category.categoryType.id
|
|
||||||
initial.value.name = props.category.name
|
|
||||||
initial.value.categoryTypeId = props.category.categoryType.id
|
|
||||||
} else {
|
|
||||||
form.value.name = ''
|
|
||||||
form.value.categoryTypeId = null
|
|
||||||
initial.value.name = ''
|
|
||||||
initial.value.categoryTypeId = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-initialiser quand la categorie selectionnee change (clic sur une autre
|
|
||||||
// ligne sans fermer le drawer entre-temps).
|
|
||||||
watch(() => props.category, resetForm, { immediate: true })
|
|
||||||
|
|
||||||
// A chaque ouverture du drawer : reset + chargement frais des types. Pas
|
|
||||||
// d'optimisation cache au M0 — le referentiel est petit et statique.
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
resetForm()
|
form.loadFrom(props.category)
|
||||||
loadCategoryTypes()
|
fetchTypes()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation client-side miroir des RG back. Renvoie true si tout passe et
|
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||||
* peuple `errors` sinon. Le serveur valide aussi (defense en profondeur) ;
|
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||||
* la validation client sert juste a eviter l'aller-retour evitable.
|
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||||
*/
|
* refresh la liste.
|
||||||
function validate(): boolean {
|
|
||||||
errors.value = { name: '', categoryType: '', _global: '' }
|
|
||||||
const trimmedName = form.value.name.trim()
|
|
||||||
|
|
||||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
|
||||||
if (trimmedName === '') {
|
|
||||||
errors.value.name = t('admin.categories.validation.nameRequired')
|
|
||||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
|
||||||
// RG-1.04 — longueur 2-120 apres trim.
|
|
||||||
errors.value.name = t('admin.categories.validation.nameLength')
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-1.05 — categoryType obligatoire.
|
|
||||||
if (form.value.categoryTypeId === null) {
|
|
||||||
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.value.name === '' && errors.value.categoryType === ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mappe une reponse 422 d'API Platform sur le state `errors`. API Platform 4
|
|
||||||
* retourne soit `violations: [{ propertyPath, message }]` soit
|
|
||||||
* `hydra:violations` selon la negociation de format.
|
|
||||||
*/
|
|
||||||
function mapServerViolations(data: unknown): boolean {
|
|
||||||
if (!data || typeof data !== 'object') return false
|
|
||||||
const record = data as Record<string, unknown>
|
|
||||||
const rawViolations = record.violations ?? record['hydra:violations']
|
|
||||||
if (!Array.isArray(rawViolations)) return false
|
|
||||||
|
|
||||||
let mapped = false
|
|
||||||
for (const v of rawViolations) {
|
|
||||||
if (!v || typeof v !== 'object') continue
|
|
||||||
const violation = v as Record<string, unknown>
|
|
||||||
const path = String(violation.propertyPath ?? '')
|
|
||||||
const message = String(violation.message ?? '')
|
|
||||||
if (path === 'name') {
|
|
||||||
errors.value.name = message
|
|
||||||
mapped = true
|
|
||||||
} else if (path === 'categoryType') {
|
|
||||||
errors.value.categoryType = message
|
|
||||||
mapped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extrait un message d'erreur HTTP au format API Platform / Hydra.
|
|
||||||
*/
|
|
||||||
function extractErrorMessage(data: unknown): string {
|
|
||||||
if (!data || typeof data !== 'object') return ''
|
|
||||||
const record = data as Record<string, unknown>
|
|
||||||
return (
|
|
||||||
(record['hydra:description'] as string)
|
|
||||||
?? (record.detail as string)
|
|
||||||
?? (record.description as string)
|
|
||||||
?? ''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sauvegarde la categorie (POST en mode create, PATCH en mode edit).
|
|
||||||
* Trim cote client (miroir RG-1.03), conversion ID → IRI pour categoryType,
|
|
||||||
* mapping des erreurs server.
|
|
||||||
*/
|
*/
|
||||||
async function handleSave(): Promise<void> {
|
async function handleSave(): Promise<void> {
|
||||||
if (!validate()) return
|
let result: Category | null = null
|
||||||
saving.value = true
|
|
||||||
errors.value._global = ''
|
|
||||||
|
|
||||||
// Trim cote client (miroir RG-1.03). Le serveur retrim de toute facon.
|
|
||||||
const payload = {
|
|
||||||
name: form.value.name.trim(),
|
|
||||||
categoryType: `/api/category_types/${form.value.categoryTypeId}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mode.value === 'create') {
|
if (mode.value === 'create') {
|
||||||
await api.post('/categories', payload, {
|
result = await form.submitCreate()
|
||||||
toastSuccessMessage: t('admin.categories.toast.created'),
|
|
||||||
toast: false, // gestion fine des erreurs ci-dessous
|
|
||||||
})
|
|
||||||
} else if (mode.value === 'edit' && props.category) {
|
} else if (mode.value === 'edit' && props.category) {
|
||||||
await api.patch(`/categories/${props.category.id}`, payload, {
|
result = await form.submitUpdate(props.category.id)
|
||||||
toastSuccessMessage: t('admin.categories.toast.updated'),
|
|
||||||
toast: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if (result) {
|
||||||
// Succes : toast manuel (car on a desactive le toast du composable
|
|
||||||
// pour gerer finement les erreurs) + propagation au parent.
|
|
||||||
useToast().success({
|
|
||||||
title: 'Succès',
|
|
||||||
message:
|
|
||||||
mode.value === 'create'
|
|
||||||
? t('admin.categories.toast.created')
|
|
||||||
: t('admin.categories.toast.updated'),
|
|
||||||
})
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
} catch (err: unknown) {
|
|
||||||
const error = err as { response?: { status?: number, _data?: unknown } }
|
|
||||||
const status = error?.response?.status
|
|
||||||
const data = error?.response?._data
|
|
||||||
|
|
||||||
if (status === 409) {
|
|
||||||
// RG-1.07 — doublon (name, categoryType). Toast custom + erreur
|
|
||||||
// mappee sur le champ name (origine du conflit).
|
|
||||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
|
||||||
name: payload.name,
|
|
||||||
})
|
|
||||||
errors.value.name = duplicateMessage
|
|
||||||
useToast().error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: duplicateMessage,
|
|
||||||
})
|
|
||||||
} else if (status === 422 && mapServerViolations(data)) {
|
|
||||||
// Violations mappees sur les champs concernes — pas de toast,
|
|
||||||
// l'utilisateur voit l'erreur directement sous le champ.
|
|
||||||
} else {
|
|
||||||
const extracted = extractErrorMessage(data)
|
|
||||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
|
||||||
useToast().error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: errors.value._global,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||||
|
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||||
|
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||||
|
vi.mock('~/shared/stores/auth', () => ({
|
||||||
|
onAuthSessionCleared: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||||
|
// global pour intercepter les appels et controler les reponses dans
|
||||||
|
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||||
|
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||||
|
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||||
|
|
||||||
|
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
|
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||||
|
|
||||||
|
const CAT_A: Category = {
|
||||||
|
id: 10,
|
||||||
|
name: 'Vis',
|
||||||
|
categoryType: TYPE_VENTE,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
}
|
||||||
|
const CAT_B: Category = {
|
||||||
|
id: 11,
|
||||||
|
name: 'Boulons',
|
||||||
|
categoryType: TYPE_VENTE,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: '2026-01-02T10:00:00+00:00',
|
||||||
|
updatedAt: '2026-01-02T10:00:00+00:00',
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||||
|
return {
|
||||||
|
totalItems: items.length,
|
||||||
|
member: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCategoriesAdmin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
// Reset systematique du state singleton entre tests : sans ca,
|
||||||
|
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||||
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
|
resetCategoriesAdmin()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchAll', () => {
|
||||||
|
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||||
|
const { fetchAll } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchAll()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
{ itemsPerPage: 999 },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple categories.value depuis le champ Hydra member', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
|
||||||
|
const { fetchAll, categories } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchAll()
|
||||||
|
|
||||||
|
expect(categories.value).toEqual([CAT_A, CAT_B])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||||
|
const { fetchAll } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchAll()
|
||||||
|
|
||||||
|
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
|
||||||
|
expect(queryArg).not.toHaveProperty('includeDeleted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ajoute includeDeleted=true quand demande explicitement', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||||
|
const { fetchAll } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchAll(true)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
{ itemsPerPage: 999, includeDeleted: 'true' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe loading a true pendant la requete et false apres', async () => {
|
||||||
|
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
|
||||||
|
mockGet.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||||
|
)
|
||||||
|
const { fetchAll, loading } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
const pending = fetchAll()
|
||||||
|
expect(loading.value).toBe(true)
|
||||||
|
|
||||||
|
resolveRequest(makeHydra<Category>([]))
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple error.value et vide categories en cas d echec', async () => {
|
||||||
|
mockGet.mockRejectedValueOnce(new Error('Network down'))
|
||||||
|
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
|
||||||
|
// Pre-charge volontairement quelque chose pour verifier la purge.
|
||||||
|
categories.value = [CAT_A]
|
||||||
|
|
||||||
|
await fetchAll()
|
||||||
|
|
||||||
|
expect(categories.value).toEqual([])
|
||||||
|
expect(error.value).toBe('Network down')
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({
|
||||||
|
totalItems: 0,
|
||||||
|
} as unknown as HydraCollection<Category>)
|
||||||
|
const { fetchAll, categories } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchAll()
|
||||||
|
|
||||||
|
expect(categories.value).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchTypes', () => {
|
||||||
|
it('appelle GET /category_types avec itemsPerPage=999', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||||
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/category_types',
|
||||||
|
{ itemsPerPage: 999 },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||||
|
const { fetchTypes, types } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple error.value et vide types en cas d echec', async () => {
|
||||||
|
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||||
|
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||||
|
types.value = [TYPE_VENTE]
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([])
|
||||||
|
expect(error.value).toContain('500')
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||||
|
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||||
|
mockGet.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||||
|
)
|
||||||
|
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
const pending = fetchTypes()
|
||||||
|
expect(loadingTypes.value).toBe(true)
|
||||||
|
|
||||||
|
resolveRequest(makeHydra<CategoryType>([]))
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetCategoriesAdmin', () => {
|
||||||
|
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||||
|
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||||
|
= useCategoriesAdmin()
|
||||||
|
// Pre-peuple le state pour verifier la purge effective.
|
||||||
|
categories.value = [CAT_A]
|
||||||
|
types.value = [TYPE_VENTE]
|
||||||
|
loading.value = true
|
||||||
|
loadingTypes.value = true
|
||||||
|
error.value = 'oops'
|
||||||
|
|
||||||
|
resetCategoriesAdmin()
|
||||||
|
|
||||||
|
expect(categories.value).toEqual([])
|
||||||
|
expect(types.value).toEqual([])
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('singleton', () => {
|
||||||
|
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
|
||||||
|
const a = useCategoriesAdmin()
|
||||||
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
|
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||||
|
// doivent etre rigoureusement les memes (state au niveau module).
|
||||||
|
expect(a.categories).toBe(b.categories)
|
||||||
|
expect(a.types).toBe(b.types)
|
||||||
|
expect(a.loading).toBe(b.loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||||
|
const a = useCategoriesAdmin()
|
||||||
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
|
a.categories.value = [CAT_A]
|
||||||
|
|
||||||
|
expect(b.categories.value).toEqual([CAT_A])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import { useCategoryForm } from '../useCategoryForm'
|
||||||
|
|
||||||
|
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockDelete = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: mockDelete,
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('useToast', () => ({
|
||||||
|
success: mockToastSuccess,
|
||||||
|
error: mockToastError,
|
||||||
|
}))
|
||||||
|
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
|
||||||
|
// Quand le composable passe des params (ex: doublon), on les serialise pour
|
||||||
|
// pouvoir verifier que l'interpolation a bien recu le bon nom.
|
||||||
|
vi.stubGlobal('useI18n', () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
|
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||||
|
|
||||||
|
const CAT: Category = {
|
||||||
|
id: 42,
|
||||||
|
name: 'Vis',
|
||||||
|
categoryType: TYPE_VENTE,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCategoryForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
mockDelete.mockReset()
|
||||||
|
mockToastSuccess.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadFrom', () => {
|
||||||
|
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
|
||||||
|
expect(form.name.value).toBe('Vis')
|
||||||
|
expect(form.categoryTypeId.value).toBe(1)
|
||||||
|
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vide le formulaire en mode creation (null)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'old'
|
||||||
|
form.categoryTypeId.value = 99
|
||||||
|
|
||||||
|
form.loadFrom(null)
|
||||||
|
|
||||||
|
expect(form.name.value).toBe('')
|
||||||
|
expect(form.categoryTypeId.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
|
||||||
|
expect(form.isDirty.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isDirty', () => {
|
||||||
|
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
expect(form.isDirty.value).toBe(false)
|
||||||
|
|
||||||
|
form.name.value = 'Vis modifie'
|
||||||
|
|
||||||
|
expect(form.isDirty.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validate', () => {
|
||||||
|
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ''
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ' '
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'A'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'A'.repeat(121)
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = null
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe quand name et categoryType sont valides', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinitialise les erreurs avant chaque validation', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
// Erreur prealable.
|
||||||
|
form.errors.value._global = 'erreur ancienne'
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
form.validate()
|
||||||
|
|
||||||
|
expect(form.errors.value._global).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitCreate', () => {
|
||||||
|
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ' Vis '
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
expect(result).toEqual(CAT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ''
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.created',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: { status: 409, _data: {} },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
|
||||||
|
// les params i18n (stub serialise les params).
|
||||||
|
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||||
|
expect(form.errors.value.name).toContain('"name":"Vis"')
|
||||||
|
expect(mockToastError).toHaveBeenCalledTimes(1)
|
||||||
|
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
|
||||||
|
expect(toastArg.message).toContain('Vis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: {
|
||||||
|
violations: [
|
||||||
|
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(form.errors.value.name).toBe('name should not be blank.')
|
||||||
|
// Pas de toast quand on a mappe les violations : l erreur est
|
||||||
|
// affichee inline sous le champ concerne.
|
||||||
|
expect(mockToastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: {
|
||||||
|
'hydra:violations': [
|
||||||
|
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(form.errors.value._global).toBe('Boom server')
|
||||||
|
expect(mockToastError).toHaveBeenCalledWith({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Boom server',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||||
|
let resolveRequest: (v: Category) => void = () => {}
|
||||||
|
mockPost.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||||
|
)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const pending = form.submitCreate()
|
||||||
|
expect(form.submitting.value).toBe(true)
|
||||||
|
|
||||||
|
resolveRequest(CAT)
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitUpdate', () => {
|
||||||
|
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/categories/42',
|
||||||
|
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.categoryTypeId.value = 2
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/categories/42',
|
||||||
|
{ categoryType: '/api/category_types/2' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
// Aucune modification — isDirty=false, patch payload vide.
|
||||||
|
|
||||||
|
const result = await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Vis V2'
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.updated',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({
|
||||||
|
response: { status: 409, _data: {} },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Doublon'
|
||||||
|
|
||||||
|
const result = await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||||
|
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitDelete', () => {
|
||||||
|
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||||
|
mockDelete.mockResolvedValueOnce(undefined)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
const ok = await form.submitDelete(42)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.deleted',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne false et toast erreur en cas d echec', async () => {
|
||||||
|
mockDelete.mockRejectedValueOnce({
|
||||||
|
response: { status: 500, _data: { detail: 'down' } },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
const ok = await form.submitDelete(42)
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value._global).toBe('down')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('vide le formulaire et les erreurs', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'edit'
|
||||||
|
form.errors.value._global = 'erreur'
|
||||||
|
form.submitting.value = true
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
|
||||||
|
expect(form.name.value).toBe('')
|
||||||
|
expect(form.categoryTypeId.value).toBeNull()
|
||||||
|
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isolation', () => {
|
||||||
|
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||||
|
const a = useCategoryForm()
|
||||||
|
const b = useCategoryForm()
|
||||||
|
|
||||||
|
a.name.value = 'A'
|
||||||
|
b.name.value = 'B'
|
||||||
|
|
||||||
|
expect(a.name.value).toBe('A')
|
||||||
|
expect(b.name.value).toBe('B')
|
||||||
|
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||||
|
expect(a.name).not.toBe(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Composable d'administration des categories (M0 — Gestion des categories).
|
||||||
|
*
|
||||||
|
* Centralise le chargement et le state des deux ressources lues par la page
|
||||||
|
* `/admin/categories` : la liste des categories et le referentiel
|
||||||
|
* CategoryType (utilise dans le select du drawer).
|
||||||
|
*
|
||||||
|
* State singleton au niveau module (meme convention que `useSidebar` /
|
||||||
|
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
||||||
|
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
||||||
|
* avec state singleton doivent etre reinitialises au logout »), et reset
|
||||||
|
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
||||||
|
* `modules/core/pages/logout.vue`.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||||
|
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||||
|
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||||
|
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||||
|
*/
|
||||||
|
const HYDRA_NO_PAGINATION = 999
|
||||||
|
|
||||||
|
// State singleton — partage entre tous les composants qui appellent le
|
||||||
|
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||||
|
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||||
|
// instance soit creee a chaque appel.
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
const types = ref<CategoryType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function resetCategoriesAdminState(): void {
|
||||||
|
categories.value = []
|
||||||
|
types.value = []
|
||||||
|
loading.value = false
|
||||||
|
loadingTypes.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
||||||
|
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||||
|
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
||||||
|
// `resetCategoriesAdmin()` ci-dessous.
|
||||||
|
onAuthSessionCleared(resetCategoriesAdminState)
|
||||||
|
|
||||||
|
export function useCategoriesAdmin() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
||||||
|
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
||||||
|
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
||||||
|
*
|
||||||
|
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
|
||||||
|
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
|
||||||
|
* option mais on l'expose pour la suite (corbeille future).
|
||||||
|
*
|
||||||
|
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
|
||||||
|
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
|
||||||
|
* la page seulement par URL directe et on affiche un tableau vide propre.
|
||||||
|
*/
|
||||||
|
async function fetchAll(includeDeleted = false): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||||
|
if (includeDeleted) {
|
||||||
|
query.includeDeleted = 'true'
|
||||||
|
}
|
||||||
|
const data = await api.get<HydraCollection<Category>>(
|
||||||
|
'/categories',
|
||||||
|
query,
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
categories.value = data.member ?? []
|
||||||
|
} catch (e) {
|
||||||
|
categories.value = []
|
||||||
|
error.value = (e as Error)?.message ?? 'Erreur de chargement'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
|
||||||
|
* l'ouverture de la page admin pour que le select du drawer ait deja les
|
||||||
|
* options pretes au moment de la creation/edition.
|
||||||
|
*
|
||||||
|
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||||
|
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||||
|
*/
|
||||||
|
async function fetchTypes(): Promise<void> {
|
||||||
|
loadingTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
|
'/category_types',
|
||||||
|
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
types.value = data.member ?? []
|
||||||
|
} catch (e) {
|
||||||
|
types.value = []
|
||||||
|
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
||||||
|
* garantir que la prochaine session reparte sur un state propre meme si
|
||||||
|
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||||
|
*/
|
||||||
|
function resetCategoriesAdmin(): void {
|
||||||
|
resetCategoriesAdminState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
types,
|
||||||
|
loading,
|
||||||
|
loadingTypes,
|
||||||
|
error,
|
||||||
|
fetchAll,
|
||||||
|
fetchTypes,
|
||||||
|
resetCategoriesAdmin,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||||
|
*
|
||||||
|
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||||
|
* DELETE) du drawer de creation/edition. Contrairement a
|
||||||
|
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||||
|
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||||
|
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||||
|
* isole.
|
||||||
|
*
|
||||||
|
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||||
|
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||||
|
* revalide toujours (defense en profondeur).
|
||||||
|
*
|
||||||
|
* Mapping erreurs API :
|
||||||
|
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
|
||||||
|
* - 422 (violations API Platform) → mapping sur les champs concernes
|
||||||
|
* - autre → erreur globale `_global` + toast generique
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
|
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||||
|
* (status et payload data) pour eviter de typer toute la lib.
|
||||||
|
*/
|
||||||
|
interface ApiFetchError {
|
||||||
|
response?: {
|
||||||
|
status?: number
|
||||||
|
_data?: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategoryForm() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||||
|
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||||
|
const name = ref('')
|
||||||
|
const categoryTypeId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||||
|
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||||
|
// que rien n'a change en mode consultation).
|
||||||
|
const initialName = ref('')
|
||||||
|
const initialCategoryTypeId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const errors = ref<{
|
||||||
|
name: string
|
||||||
|
categoryType: string
|
||||||
|
_global: string
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
categoryType: '',
|
||||||
|
_global: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
name.value !== initialName.value
|
||||||
|
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||||
|
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||||
|
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||||
|
*/
|
||||||
|
function loadFrom(category: Category | null): void {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
if (category) {
|
||||||
|
name.value = category.name
|
||||||
|
categoryTypeId.value = category.categoryType.id
|
||||||
|
initialName.value = category.name
|
||||||
|
initialCategoryTypeId.value = category.categoryType.id
|
||||||
|
} else {
|
||||||
|
name.value = ''
|
||||||
|
categoryTypeId.value = null
|
||||||
|
initialName.value = ''
|
||||||
|
initialCategoryTypeId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||||
|
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||||
|
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||||
|
*/
|
||||||
|
function validate(): boolean {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
const trimmedName = name.value.trim()
|
||||||
|
|
||||||
|
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||||
|
if (trimmedName === '') {
|
||||||
|
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||||
|
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||||
|
// RG-1.04 — longueur 2-120 apres trim.
|
||||||
|
errors.value.name = t('admin.categories.validation.nameLength')
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.05 — categoryType obligatoire.
|
||||||
|
if (categoryTypeId.value === null) {
|
||||||
|
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.value.name === '' && errors.value.categoryType === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||||
|
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||||
|
* Platform pour referencer une ressource liee. Retourne un object literal
|
||||||
|
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
|
||||||
|
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
|
||||||
|
* en TS strict).
|
||||||
|
*/
|
||||||
|
function buildCreatePayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
name: name.value.trim(),
|
||||||
|
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||||
|
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||||
|
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||||
|
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||||
|
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||||
|
* sur les futurs drawers de formulaire.
|
||||||
|
*/
|
||||||
|
function mapServerViolations(data: unknown): boolean {
|
||||||
|
const violations = extractApiViolations(data)
|
||||||
|
if (violations.length === 0) return false
|
||||||
|
let mapped = false
|
||||||
|
for (const v of violations) {
|
||||||
|
if (v.propertyPath === 'name') {
|
||||||
|
errors.value.name = v.message
|
||||||
|
mapped = true
|
||||||
|
} else if (v.propertyPath === 'categoryType') {
|
||||||
|
errors.value.categoryType = v.message
|
||||||
|
mapped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||||
|
* appropries. Centralise la logique entre create/update.
|
||||||
|
*
|
||||||
|
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||||
|
* le nom soumis.
|
||||||
|
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||||
|
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||||
|
* sous le champ concerne).
|
||||||
|
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||||
|
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||||
|
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||||
|
*
|
||||||
|
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||||
|
* false sinon (fallback generique).
|
||||||
|
*/
|
||||||
|
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||||
|
const status = (e as ApiFetchError)?.response?.status
|
||||||
|
const data = (e as ApiFetchError)?.response?._data
|
||||||
|
|
||||||
|
if (status === 409) {
|
||||||
|
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||||
|
name: attemptedName,
|
||||||
|
})
|
||||||
|
errors.value.name = duplicateMessage
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: duplicateMessage,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 422 && mapServerViolations(data)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = extractApiErrorMessage(data)
|
||||||
|
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: errors.value._global,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||||
|
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||||
|
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||||
|
*/
|
||||||
|
async function submitCreate(): Promise<Category | null> {
|
||||||
|
if (!validate()) return null
|
||||||
|
submitting.value = true
|
||||||
|
errors.value._global = ''
|
||||||
|
const payload = buildCreatePayload()
|
||||||
|
try {
|
||||||
|
const created = await api.post<Category>('/categories', payload, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: t('admin.categories.toast.created'),
|
||||||
|
})
|
||||||
|
return created
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, String(payload.name))
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||||
|
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||||
|
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||||
|
*/
|
||||||
|
async function submitUpdate(id: number): Promise<Category | null> {
|
||||||
|
if (!validate()) return null
|
||||||
|
submitting.value = true
|
||||||
|
errors.value._global = ''
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
if (name.value !== initialName.value) {
|
||||||
|
payload.name = name.value.trim()
|
||||||
|
}
|
||||||
|
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||||
|
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||||
|
}
|
||||||
|
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||||
|
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||||
|
// on protege le composable contre un appel direct mal utilise.
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
submitting.value = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: t('admin.categories.toast.updated'),
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
} catch (e) {
|
||||||
|
const attemptedName = typeof payload.name === 'string'
|
||||||
|
? payload.name
|
||||||
|
: name.value.trim()
|
||||||
|
handleApiError(e, attemptedName)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||||
|
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||||
|
* false sinon (avec toast erreur deja affiche).
|
||||||
|
*/
|
||||||
|
async function submitDelete(id: number): Promise<boolean> {
|
||||||
|
submitting.value = true
|
||||||
|
errors.value._global = ''
|
||||||
|
try {
|
||||||
|
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: t('admin.categories.toast.deleted'),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, name.value)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||||
|
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||||
|
*/
|
||||||
|
function reset(): void {
|
||||||
|
name.value = ''
|
||||||
|
categoryTypeId.value = null
|
||||||
|
initialName.value = ''
|
||||||
|
initialCategoryTypeId.value = null
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
name,
|
||||||
|
categoryTypeId,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isDirty,
|
||||||
|
// Methods
|
||||||
|
loadFrom,
|
||||||
|
validate,
|
||||||
|
submitCreate,
|
||||||
|
submitUpdate,
|
||||||
|
submitDelete,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||||
|
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="categoryItems"
|
:items="categoryItems"
|
||||||
@@ -48,18 +47,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Category } from '~/modules/catalog/types/category'
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||||
|
const { submitDelete } = useCategoryForm()
|
||||||
|
|
||||||
useHead({ title: t('admin.categories.title') })
|
useHead({ title: t('admin.categories.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('catalog.categories.manage'))
|
const canManage = computed(() => can('catalog.categories.manage'))
|
||||||
|
|
||||||
const categories = ref<Category[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedCategory = ref<Category | null>(null)
|
const selectedCategory = ref<Category | null>(null)
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
@@ -90,35 +87,6 @@ function onRowClick(item: Record<string, unknown>) {
|
|||||||
if (category) openEditDrawer(category)
|
if (category) openEditDrawer(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
|
||||||
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
|
||||||
* serveur (volumetrie cible <= 300) ni de slice client — toute la liste
|
|
||||||
* est rendue d'un coup ; la barre du MalioDataTable est donc cosmetique
|
|
||||||
* jusqu'a la mise a jour layer-ui (ticket ERP-70).
|
|
||||||
*
|
|
||||||
* Logique inline volontaire au M0 (decision prompt ERP-49) : extraction
|
|
||||||
* en composable `useCategoriesAdmin` au ticket 0.8 (ERP-50).
|
|
||||||
*/
|
|
||||||
async function loadCategories(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<HydraCollection<Category>>(
|
|
||||||
'/categories',
|
|
||||||
{ itemsPerPage: 999 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
categories.value = data.member ?? []
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale. Pas de
|
|
||||||
// toast : un user sans permission view recoit 403 et voit une
|
|
||||||
// liste vide propre — le mecanisme de gating se fait cote sidebar.
|
|
||||||
categories.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedCategory.value = null
|
selectedCategory.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
@@ -136,32 +104,36 @@ function onDeleteRequest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||||
* `deleted_at = now()` et retourne 204. Refresh de la liste a la fin
|
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||||
* pour retirer la ligne (l'index unique partiel autorise une recreation
|
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||||
* ulterieure avec le meme couple (name, type) — RG-1.07).
|
* RG-1.07.
|
||||||
*/
|
*/
|
||||||
async function handleDelete(): Promise<void> {
|
async function handleDelete(): Promise<void> {
|
||||||
if (!categoryToDelete.value) return
|
if (!categoryToDelete.value) return
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await api.delete(`/categories/${categoryToDelete.value.id}`, {}, {
|
const ok = await submitDelete(categoryToDelete.value.id)
|
||||||
toastSuccessMessage: t('admin.categories.toast.deleted'),
|
if (ok) {
|
||||||
})
|
|
||||||
deleteModalOpen.value = false
|
deleteModalOpen.value = false
|
||||||
categoryToDelete.value = null
|
categoryToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
await loadCategories()
|
await fetchAll()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCategorySaved() {
|
function onCategorySaved() {
|
||||||
loadCategories()
|
fetchAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||||
|
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||||
|
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCategories()
|
fetchAll()
|
||||||
|
fetchTypes()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
|
|||||||
const { resetModules } = useModules()
|
const { resetModules } = useModules()
|
||||||
const { resetCurrentSite } = useCurrentSite()
|
const { resetCurrentSite } = useCurrentSite()
|
||||||
const { resetAuditLog } = useAuditLog()
|
const { resetAuditLog } = useAuditLog()
|
||||||
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +28,7 @@ onMounted(async () => {
|
|||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
resetAuditLog()
|
resetAuditLog()
|
||||||
|
resetCategoriesAdmin()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FetchOptions , FetchError } from 'ofetch'
|
import type { FetchOptions , FetchError } from 'ofetch'
|
||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
export type AnyObject = Record<string, unknown>
|
export type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
|||||||
|
|
||||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||||
const data = responseData ?? (error as FetchError)?.data
|
const data = responseData ?? (error as FetchError)?.data
|
||||||
|
const msg = extractApiErrorMessage(data)
|
||||||
if (typeof data === 'string') {
|
if (msg) return msg
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
const record = data as Record<string, unknown>
|
|
||||||
return (
|
|
||||||
(record['hydra:description'] as string) ||
|
|
||||||
(record.detail as string) ||
|
|
||||||
(record.message as string) ||
|
|
||||||
(record.error as string) ||
|
|
||||||
(record.title as string) ||
|
|
||||||
(record['hydra:title'] as string) ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
|||||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||||
return collection.member ?? []
|
return collection.member ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||||
|
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||||
|
*/
|
||||||
|
export interface ApiViolation {
|
||||||
|
propertyPath: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||||
|
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||||
|
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||||
|
*
|
||||||
|
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||||
|
* doit mapper les violations serveur sur ses champs.
|
||||||
|
*/
|
||||||
|
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||||
|
if (!data || typeof data !== 'object') return []
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
const raw = record.violations ?? record['hydra:violations']
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: ApiViolation[] = []
|
||||||
|
for (const v of raw) {
|
||||||
|
if (!v || typeof v !== 'object') continue
|
||||||
|
const obj = v as Record<string, unknown>
|
||||||
|
out.push({
|
||||||
|
propertyPath: String(obj.propertyPath ?? ''),
|
||||||
|
message: String(obj.message ?? ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||||
|
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||||
|
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||||
|
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||||
|
*
|
||||||
|
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||||
|
* Symfony en text/plain ou des messages bruts).
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(data: unknown): string {
|
||||||
|
if (typeof data === 'string') return data
|
||||||
|
if (!data || typeof data !== 'object') return ''
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
(record['hydra:description'] as string)
|
||||||
|
?? (record.detail as string)
|
||||||
|
?? (record.description as string)
|
||||||
|
?? (record.message as string)
|
||||||
|
?? (record.error as string)
|
||||||
|
?? (record.title as string)
|
||||||
|
?? (record['hydra:title'] as string)
|
||||||
|
?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,10 +207,16 @@ migration-migrate:
|
|||||||
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
||||||
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
||||||
# POST doublons remontent 201 au lieu de 409.
|
# POST doublons remontent 201 au lieu de 409.
|
||||||
|
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
|
||||||
|
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||||
|
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
||||||
|
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
|
||||||
|
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
|
||||||
test-db-setup:
|
test-db-setup:
|
||||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||||
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
|
||||||
|
* tables metier existantes.
|
||||||
|
*
|
||||||
|
* Postgres stocke la description dans `pg_description`. Les outils d'admin
|
||||||
|
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
|
||||||
|
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
|
||||||
|
*
|
||||||
|
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
|
||||||
|
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
|
||||||
|
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
|
||||||
|
* absents du mapping PHP).
|
||||||
|
*
|
||||||
|
* Convention :
|
||||||
|
* - Description en francais, ≤ 200 caracteres.
|
||||||
|
* - Semantique du champ + contraintes / lien RG si pertinent.
|
||||||
|
*
|
||||||
|
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||||
|
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
|
||||||
|
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
|
||||||
|
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
|
||||||
|
*/
|
||||||
|
final class Version20260528120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) {
|
||||||
|
$this->addSql($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
foreach ($entries as $column => $_) {
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS NULL',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:apply-column-comments',
|
||||||
|
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
|
||||||
|
)]
|
||||||
|
final class ApplyColumnCommentsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$statements = ColumnCommentsCatalog::toSqlStatements();
|
||||||
|
|
||||||
|
foreach ($statements as $sql) {
|
||||||
|
$this->connection->executeStatement($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
|
||||||
|
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
|
||||||
|
*
|
||||||
|
* Source unique de verite, utilisee par :
|
||||||
|
* - `migrations/Version20260528120000.php` : retrofit initial des tables
|
||||||
|
* pre-existantes (ERP-67).
|
||||||
|
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
|
||||||
|
* reapplique les commentaires apres `doctrine:schema:update --force` en
|
||||||
|
* environnement de test (cf. commentaire de `test-db-setup` dans le
|
||||||
|
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
|
||||||
|
* du mapping PHP, on les rejoue depuis ce catalogue.
|
||||||
|
*
|
||||||
|
* Pour ajouter ou modifier un commentaire :
|
||||||
|
* - Mettre a jour `comments()` ci-dessous.
|
||||||
|
* - La migration retrofit pose la valeur initiale, la commande la rejoue
|
||||||
|
* en boucle. Toute future colonne doit etre documentee dans sa propre
|
||||||
|
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
|
||||||
|
* qu'au retrofit + au workaround schema:update.
|
||||||
|
*
|
||||||
|
* Convention : description en francais, ≤ 200 caracteres, semantique du
|
||||||
|
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
|
||||||
|
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
|
||||||
|
*/
|
||||||
|
final class ColumnCommentsCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, string>>
|
||||||
|
*/
|
||||||
|
public static function comments(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'audit_log' => [
|
||||||
|
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||||
|
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||||
|
'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.",
|
||||||
|
'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).",
|
||||||
|
'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.",
|
||||||
|
'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).',
|
||||||
|
'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.",
|
||||||
|
'performed_at' => "Horodatage UTC de l'action auditee.",
|
||||||
|
'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.",
|
||||||
|
'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.",
|
||||||
|
],
|
||||||
|
|
||||||
|
'category' => [
|
||||||
|
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||||
|
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||||
|
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||||
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
|
'category_type' => [
|
||||||
|
'_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.',
|
||||||
|
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
||||||
|
],
|
||||||
|
|
||||||
|
'permission' => [
|
||||||
|
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.',
|
||||||
|
'label' => 'Libelle affichable de la permission (FR).',
|
||||||
|
'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).',
|
||||||
|
'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.",
|
||||||
|
],
|
||||||
|
|
||||||
|
'role' => [
|
||||||
|
'_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.',
|
||||||
|
'label' => 'Libelle affichable du role (FR).',
|
||||||
|
'description' => 'Description longue du role (optionnelle).',
|
||||||
|
'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.",
|
||||||
|
],
|
||||||
|
|
||||||
|
'role_permission' => [
|
||||||
|
'_table' => 'Table de jointure roles <-> permissions (ManyToMany).',
|
||||||
|
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.',
|
||||||
|
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'site' => [
|
||||||
|
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||||
|
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||||
|
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||||
|
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||||
|
'street' => "Numero et voie de l'adresse (≤ 200 caracteres).",
|
||||||
|
'complement' => "Complement d'adresse (etage, batiment...) — optionnel.",
|
||||||
|
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||||
|
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'user' => [
|
||||||
|
'_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.',
|
||||||
|
'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].',
|
||||||
|
'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).',
|
||||||
|
'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.',
|
||||||
|
'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.",
|
||||||
|
],
|
||||||
|
|
||||||
|
'user_permission' => [
|
||||||
|
'_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).',
|
||||||
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.',
|
||||||
|
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'user_role' => [
|
||||||
|
'_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).',
|
||||||
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.',
|
||||||
|
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'user_site' => [
|
||||||
|
'_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).',
|
||||||
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
||||||
|
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptions standardisees pour les 4 colonnes du pattern
|
||||||
|
* Timestampable/Blamable (`TimestampableBlamableTrait`).
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function timestampableBlamableComments(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||||
|
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||||
|
'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.",
|
||||||
|
'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
||||||
|
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function toSqlStatements(): array
|
||||||
|
{
|
||||||
|
$statements = [];
|
||||||
|
foreach (self::comments() as $table => $entries) {
|
||||||
|
$quotedTable = self::quoteIdent($table);
|
||||||
|
foreach ($entries as $column => $description) {
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statements[] = sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
self::quoteIdent($column),
|
||||||
|
$description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote un identifiant SQL avec des guillemets doubles. Necessaire pour
|
||||||
|
* la table `user` (mot reserve PG) ; applique a tous par coherence.
|
||||||
|
*/
|
||||||
|
private static function quoteIdent(string $name): string
|
||||||
|
{
|
||||||
|
return '"'.str_replace('"', '""', $name).'"';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fou architecture : toute colonne d'une table metier doit porter une
|
||||||
|
* description SQL (`COMMENT ON COLUMN`).
|
||||||
|
*
|
||||||
|
* Postgres stocke la description dans `pg_description`, recuperable via
|
||||||
|
* `col_description(table_oid, column_position)`. Une colonne sans description
|
||||||
|
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
|
||||||
|
* schema `public` et echoue si une seule colonne metier n'a pas de description.
|
||||||
|
*
|
||||||
|
* Tables ignorees :
|
||||||
|
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
|
||||||
|
* librairie.
|
||||||
|
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
|
||||||
|
* doit avoir un ticket Lesstime ouvert pour le retrofit.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ColumnsHaveSqlCommentTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
|
||||||
|
*/
|
||||||
|
private const EXCLUDED_BUILTINS = [
|
||||||
|
'doctrine_migration_versions',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
|
||||||
|
* tests d'integration, jamais en prod). Pas de migration, donc pas de
|
||||||
|
* lieu naturel pour poser un COMMENT ON COLUMN.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const EXCLUDED_TEST_FIXTURES = [
|
||||||
|
// tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module
|
||||||
|
// Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update
|
||||||
|
// sur la DB de test uniquement.
|
||||||
|
'fake_site_aware_entity',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelist metier — DOIT rester vide ou justifiee.
|
||||||
|
*
|
||||||
|
* Chaque entree doit comporter (1) un commentaire expliquant pourquoi la
|
||||||
|
* table n'est pas encore documentee et (2) la reference d'un ticket
|
||||||
|
* Lesstime ouvert pour le retrofit.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const EXCLUDED_TABLES = [];
|
||||||
|
|
||||||
|
public function testAllPublicColumnsHaveASqlComment(): void
|
||||||
|
{
|
||||||
|
/** @var Connection $conn */
|
||||||
|
$conn = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||||
|
|
||||||
|
$excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES];
|
||||||
|
|
||||||
|
$rows = $conn->fetchAllAssociative(
|
||||||
|
<<<'SQL'
|
||||||
|
SELECT c.table_name, c.column_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = 'public'
|
||||||
|
AND c.table_name NOT IN (:excluded)
|
||||||
|
AND col_description(
|
||||||
|
(c.table_schema || '.' || c.table_name)::regclass,
|
||||||
|
c.ordinal_position
|
||||||
|
) IS NULL
|
||||||
|
ORDER BY c.table_name, c.ordinal_position
|
||||||
|
SQL,
|
||||||
|
['excluded' => $excluded],
|
||||||
|
['excluded' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] !== $rows) {
|
||||||
|
$missing = array_map(
|
||||||
|
static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']),
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::fail(sprintf(
|
||||||
|
"%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s",
|
||||||
|
count($missing),
|
||||||
|
implode("\n - ", $missing),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde : si la requete ne renvoie rien et qu'aucune table publique
|
||||||
|
// n'existe (sauf doctrine_migration_versions), le test deviendrait un
|
||||||
|
// faux positif vert. On verifie qu'il y a bien des tables a auditer.
|
||||||
|
$tableCount = (int) $conn->fetchOne(
|
||||||
|
<<<'SQL'
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name NOT IN (:excluded)
|
||||||
|
SQL,
|
||||||
|
['excluded' => $excluded],
|
||||||
|
['excluded' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user