Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9c881c771 | |||
| 97301dcd6c | |||
| daeb8b3003 | |||
| 9c311cb58b |
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
|
|||||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||||
|
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
|
||||||
|
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
|
||||||
|
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
|
||||||
|
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
|
||||||
|
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
|
||||||
|
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
|
||||||
|
# aussi en prod si les noeuds derivent legerement entre eux.
|
||||||
|
clock_skew: 15
|
||||||
remove_token_from_body_when_cookies_used: true
|
remove_token_from_body_when_cookies_used: true
|
||||||
token_extractors:
|
token_extractors:
|
||||||
authorization_header:
|
authorization_header:
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.68'
|
app.version: '0.1.69'
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||||
|
// les appels de chargement des referentiels et simuler un endpoint en echec
|
||||||
|
// (ex: 403 sur /categories pour un role sans la permission de lecture).
|
||||||
|
// Meme pattern que useClientsRepository.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 le stub pour que useApi soit bien resolu au top-level du module.
|
||||||
|
const { useClientReferentials } = await import('../useClientReferentials')
|
||||||
|
|
||||||
|
describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => {
|
||||||
|
// /categories rejette (simulateur d'un 403), tous les autres repondent.
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.reject(new Error('403 Forbidden'))
|
||||||
|
}
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = useClientReferentials()
|
||||||
|
// loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole.
|
||||||
|
await refs.loadCommon()
|
||||||
|
|
||||||
|
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
|
||||||
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
||||||
|
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
|
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
|
|
||||||
|
// Seul le select en echec reste vide.
|
||||||
|
expect(refs.categories.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge tous les referentiels quand tout repond', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({
|
||||||
|
member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = useClientReferentials()
|
||||||
|
await refs.loadCommon()
|
||||||
|
|
||||||
|
expect(refs.categories.value).toEqual([
|
||||||
|
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
|
||||||
|
])
|
||||||
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,23 +88,35 @@ export function useClientReferentials() {
|
|||||||
* charges a la demande selon la relation choisie). Les selects compta ne sont
|
* charges a la demande selon la relation choisie). Les selects compta ne sont
|
||||||
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
|
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
|
||||||
* negligeable et simplifie l'orchestration.
|
* negligeable et simplifie l'orchestration.
|
||||||
|
*
|
||||||
|
* Resilience (ERP-102) : chaque referentiel est charge et affecte
|
||||||
|
* independamment via `Promise.allSettled`. Si UN endpoint echoue (ex: 403,
|
||||||
|
* coupure reseau), seul SON select reste vide — les autres sont peuples
|
||||||
|
* normalement. Un `Promise.all` rejetterait au premier echec et viderait la
|
||||||
|
* TOTALITE des selects, rendant le formulaire de creation client inutilisable.
|
||||||
|
* `loadCommon` ne rejette donc jamais.
|
||||||
*/
|
*/
|
||||||
async function loadCommon(): Promise<void> {
|
async function loadCommon(): Promise<void> {
|
||||||
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([
|
await Promise.allSettled([
|
||||||
fetchAll<CategoryMember>('/categories'),
|
fetchAll<CategoryMember>('/categories').then(cats => {
|
||||||
fetchAll<SiteMember>('/sites'),
|
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
|
||||||
fetchAll<ReferentialMember>('/tva_modes'),
|
}),
|
||||||
fetchAll<ReferentialMember>('/payment_delays'),
|
fetchAll<SiteMember>('/sites').then(sitesList => {
|
||||||
fetchAll<ReferentialMember>('/payment_types'),
|
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
|
||||||
fetchAll<ReferentialMember>('/banks'),
|
}),
|
||||||
|
fetchAll<ReferentialMember>('/tva_modes').then(tva => {
|
||||||
|
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
|
||||||
|
}),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays').then(delays => {
|
||||||
|
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
|
||||||
|
}),
|
||||||
|
fetchAll<ReferentialMember>('/payment_types').then(types => {
|
||||||
|
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
|
||||||
|
}),
|
||||||
|
fetchAll<ReferentialMember>('/banks').then(banksList => {
|
||||||
|
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
|
|
||||||
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
|
|
||||||
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
|
|
||||||
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
|
|
||||||
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
|
|
||||||
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ final class CatalogModule
|
|||||||
return [
|
return [
|
||||||
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||||
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||||
|
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les categories
|
||||||
|
// pour alimenter les selects des modules Tiers (clients, fournisseurs...),
|
||||||
|
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||||
|
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||||
|
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
|
// Lecture (liste + item) : permission d'administration `view` OU permission
|
||||||
|
// de lecture-referentiel transverse `read_ref` (ERP-102). Les referentiels
|
||||||
|
// categories sont consommes par les modules Tiers (selects creation/filtre
|
||||||
|
// client) : tout role qui gere des tiers doit pouvoir les lire sans porter
|
||||||
|
// l'acces admin du Catalogue. `read_ref` est une permission Catalog (pas un
|
||||||
|
// code d'un autre module) -> isolement inter-module preserve.
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
security: "is_granted('catalog.categories.view')",
|
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
provider: CategoryProvider::class,
|
provider: CategoryProvider::class,
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('catalog.categories.view')",
|
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
provider: CategoryProvider::class,
|
provider: CategoryProvider::class,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
|
|||||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||||
* partagent strictement la meme logique de selection.
|
* partagent strictement la meme logique de selection.
|
||||||
*
|
*
|
||||||
|
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
|
||||||
|
* l'hydratation des collections affichees est une decision de l'appelant
|
||||||
|
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
|
||||||
|
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
|
||||||
|
* (ERP-100).
|
||||||
|
*
|
||||||
* @param list<string> $categoryCodes
|
* @param list<string> $categoryCodes
|
||||||
* @param list<int> $siteIds
|
* @param list<int> $siteIds
|
||||||
*/
|
*/
|
||||||
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
|
|||||||
array $siteIds = [],
|
array $siteIds = [],
|
||||||
bool $archivedOnly = false,
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder;
|
): QueryBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate en lot les collections affichees par le repertoire (categories,
|
||||||
|
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
|
||||||
|
* map Doctrine (memes instances). A appeler apres une selection bornee (page
|
||||||
|
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
|
||||||
|
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
|
||||||
|
*
|
||||||
|
* Charge les categories et les adresses/sites en DEUX requetes distinctes
|
||||||
|
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
|
||||||
|
* x sites en un seul produit cartesien.
|
||||||
|
*
|
||||||
|
* @param list<Client> $clients
|
||||||
|
*/
|
||||||
|
public function hydrateListCollections(array $clients): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
|
|||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
// @var list<Client> $result
|
/** @var list<Client> $clients */
|
||||||
return $qb->getQuery()->getResult();
|
$clients = $qb->getQuery()->getResult();
|
||||||
|
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
|
||||||
|
// le N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||||
|
$this->repository->hydrateListCollections($clients);
|
||||||
|
|
||||||
|
return $clients;
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $this->pagination->getLimit($operation, $context);
|
$limit = $this->pagination->getLimit($operation, $context);
|
||||||
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
|
|||||||
|
|
||||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||||
|
|
||||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
// Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
|
||||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
// puis on hydrate ses collections en lot (memes entites managees).
|
||||||
|
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||||
|
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||||
|
|
||||||
|
return $paginator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ final class ClientExportController
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
|
|
||||||
|
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
|
||||||
|
// selection ne fetch-join plus, on remplit les collections en 2 requetes
|
||||||
|
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
|
||||||
|
$this->repository->hydrateListCollections($clients);
|
||||||
|
|
||||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||||
|
|
||||||
$binary = $this->exporter->export(
|
$binary = $this->exporter->export(
|
||||||
|
|||||||
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
array $siteIds = [],
|
array $siteIds = [],
|
||||||
bool $archivedOnly = false,
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder {
|
): QueryBuilder {
|
||||||
|
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
|
||||||
|
// L'hydratation des collections affichees (Catégories / Site(s)) est
|
||||||
|
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
|
||||||
|
// imposer un produit cartesien aux chemins non pagines (export,
|
||||||
|
// ?pagination=false) — ERP-100.
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
// Jointures + addSelect pour hydrater en une seule requete les
|
|
||||||
// collections affichees par le Repertoire (colonnes Catégories /
|
|
||||||
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
|
|
||||||
// requete par client, puis par adresse). Le Paginator ORM
|
|
||||||
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
|
|
||||||
// malgre ces jointures to-many.
|
|
||||||
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
|
||||||
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
|
||||||
->leftJoin('addr.sites', 'site')->addSelect('site')
|
|
||||||
->andWhere('c.deletedAt IS NULL')
|
->andWhere('c.deletedAt IS NULL')
|
||||||
->orderBy('c.companyName', 'ASC')
|
->orderBy('c.companyName', 'ASC')
|
||||||
;
|
;
|
||||||
@@ -66,6 +62,46 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hydrateListCollections(array $clients): void
|
||||||
|
{
|
||||||
|
if ([] === $clients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids des clients deja charges (entites managees). On rehydrate leurs
|
||||||
|
// collections via l'identity map : les requetes ci-dessous renvoient les
|
||||||
|
// MEMES instances Client, dont les collections sont alors remplies.
|
||||||
|
$ids = [];
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
$id = $client->getId();
|
||||||
|
if (null !== $id) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ([] === $ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
|
||||||
|
$this->createQueryBuilder('c')
|
||||||
|
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
||||||
|
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
|
||||||
|
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
|
||||||
|
// plus multiplie par les categories : le cartesien global est casse.
|
||||||
|
$this->createQueryBuilder('c')
|
||||||
|
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
||||||
|
->leftJoin('addr.sites', 'site')->addSelect('site')
|
||||||
|
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ final class RbacSeeder
|
|||||||
'permissions' => [
|
'permissions' => [
|
||||||
'commercial.clients.view',
|
'commercial.clients.view',
|
||||||
'commercial.clients.manage',
|
'commercial.clients.manage',
|
||||||
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
|
'catalog.categories.read_ref',
|
||||||
|
'sites.read_ref',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::ROLE_COMPTA => [
|
self::ROLE_COMPTA => [
|
||||||
@@ -70,6 +73,9 @@ final class RbacSeeder
|
|||||||
'commercial.clients.view',
|
'commercial.clients.view',
|
||||||
'commercial.clients.accounting.view',
|
'commercial.clients.accounting.view',
|
||||||
'commercial.clients.accounting.manage',
|
'commercial.clients.accounting.manage',
|
||||||
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
|
'catalog.categories.read_ref',
|
||||||
|
'sites.read_ref',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::ROLE_COMMERCIALE => [
|
self::ROLE_COMMERCIALE => [
|
||||||
@@ -77,6 +83,9 @@ final class RbacSeeder
|
|||||||
'permissions' => [
|
'permissions' => [
|
||||||
'commercial.clients.view',
|
'commercial.clients.view',
|
||||||
'commercial.clients.manage',
|
'commercial.clients.manage',
|
||||||
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
|
'catalog.categories.read_ref',
|
||||||
|
'sites.read_ref',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::ROLE_USINE => [
|
self::ROLE_USINE => [
|
||||||
|
|||||||
@@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
|
// Lecture (liste + item) : permission d'administration `sites.view` OU
|
||||||
|
// permission de lecture-referentiel transverse `sites.read_ref` (ERP-102).
|
||||||
|
// Le referentiel sites alimente les selects d'adresse des modules Tiers :
|
||||||
|
// tout role qui gere des tiers doit pouvoir le lire sans porter l'acces
|
||||||
|
// admin des Sites.
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
normalizationContext: ['groups' => ['site:read']],
|
normalizationContext: ['groups' => ['site:read']],
|
||||||
security: "is_granted('sites.view')",
|
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
normalizationContext: ['groups' => ['site:read']],
|
normalizationContext: ['groups' => ['site:read']],
|
||||||
security: "is_granted('sites.view')",
|
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
normalizationContext: ['groups' => ['site:read']],
|
normalizationContext: ['groups' => ['site:read']],
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ final class SitesModule
|
|||||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||||
|
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les sites
|
||||||
|
// pour alimenter les selects des modules Tiers (adresses client...), sans
|
||||||
|
// donner l'acces d'administration `.view` (qui ouvre la page Sites dans la
|
||||||
|
// sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||||
|
['code' => 'sites.read_ref', 'label' => 'Lire le referentiel sites (transverse, lecture seule)'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
|
|||||||
|
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
#[AsDoctrineListener(event: Events::preUpdate)]
|
#[AsDoctrineListener(event: Events::preUpdate)]
|
||||||
final class TimestampableBlamableSubscriber
|
final class TimestampableBlamableSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private readonly Security $security) {}
|
// L'horloge est injectee (et non un `new DateTimeImmutable()` direct) pour
|
||||||
|
// que les tests puissent figer/avancer le temps de facon deterministe via
|
||||||
|
// ClockSensitiveTrait (cf. ERP-98). En prod, le service `clock` delegue a
|
||||||
|
// l'horloge systeme reelle.
|
||||||
|
public function __construct(
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function prePersist(PrePersistEventArgs $args): void
|
public function prePersist(PrePersistEventArgs $args): void
|
||||||
{
|
{
|
||||||
$entity = $args->getObject();
|
$entity = $args->getObject();
|
||||||
$now = new DateTimeImmutable();
|
$now = $this->clock->now();
|
||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
if ($entity instanceof TimestampableInterface) {
|
if ($entity instanceof TimestampableInterface) {
|
||||||
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
|
|||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
if ($entity instanceof TimestampableInterface) {
|
if ($entity instanceof TimestampableInterface) {
|
||||||
$entity->setUpdatedAt(new DateTimeImmutable());
|
$entity->setUpdatedAt($this->clock->now());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
|
|||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||||
@@ -20,12 +22,39 @@ use DateTimeImmutable;
|
|||||||
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||||
* Doctrine declenche le subscriber)
|
* Doctrine declenche le subscriber)
|
||||||
*
|
*
|
||||||
|
* ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
|
||||||
|
* que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
|
||||||
|
* que `self::mockTime()` remplace par un MockClock fige au niveau du process —
|
||||||
|
* ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
|
||||||
|
* et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||||
{
|
{
|
||||||
|
use ClockSensitiveTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
|
||||||
|
* defaut, et la retourne pour la piloter (`sleep()`).
|
||||||
|
*
|
||||||
|
* Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
|
||||||
|
* les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
|
||||||
|
* PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
|
||||||
|
* (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
|
||||||
|
* (fuseau par defaut), exactement comme le NativeClock en prod.
|
||||||
|
*/
|
||||||
|
private function freezeClock(): ClockInterface
|
||||||
|
{
|
||||||
|
return self::mockTime(new DateTimeImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreatedByAdminOnPost(): void
|
public function testCreatedByAdminOnPost(): void
|
||||||
{
|
{
|
||||||
|
// Horloge figee : le subscriber posera createdAt/updatedAt sur cet
|
||||||
|
// instant exact, insensible a tout decalage d'horloge reel.
|
||||||
|
$clock = $this->freezeClock();
|
||||||
|
|
||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
|
|
||||||
/** @var User $admin */
|
/** @var User $admin */
|
||||||
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
self::assertNotNull($admin);
|
self::assertNotNull($admin);
|
||||||
$adminId = $admin->getId();
|
$adminId = $admin->getId();
|
||||||
|
|
||||||
$before = new DateTimeImmutable();
|
$before = $clock->now();
|
||||||
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
|
||||||
sleep(1);
|
|
||||||
|
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
|
|
||||||
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||||
{
|
{
|
||||||
|
$clock = $this->freezeClock();
|
||||||
|
|
||||||
// Etape 1 : creation par admin pour figer createdBy=admin.
|
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
$adminClient = $this->createAdminClient();
|
$adminClient = $this->createAdminClient();
|
||||||
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||||
$initialCreatedById = $initial->getCreatedBy()->getId();
|
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||||
|
|
||||||
// Decalage temporel suffisant pour que la precision PG (seconde)
|
// Avance deterministe de l'horloge mockee : garantit un updatedAt
|
||||||
// capte un updatedAt different.
|
// strictement superieur cote PG (precision seconde) sans sleep reel.
|
||||||
sleep(1);
|
$clock->sleep(1);
|
||||||
|
|
||||||
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||||
$manage = $this->createManageClient();
|
$manage = $this->createManageClient();
|
||||||
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
|
|
||||||
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||||
{
|
{
|
||||||
|
$clock = $this->freezeClock();
|
||||||
|
|
||||||
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||||
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||||
|
|
||||||
sleep(1);
|
// Avance deterministe de l'horloge mockee (cf. testPatch).
|
||||||
|
$clock->sleep(1);
|
||||||
|
|
||||||
// Soft delete par un manager non-admin.
|
// Soft delete par un manager non-admin.
|
||||||
$manage = $this->createManageClient();
|
$manage = $this->createManageClient();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertNotContains('SECTEUR CO', $names);
|
self::assertNotContains('SECTEUR CO', $names);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
|
||||||
|
* liste ne fetch-join plus les collections — l'export les recharge en lot via
|
||||||
|
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
|
||||||
|
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
|
||||||
|
* sans erreur).
|
||||||
|
*/
|
||||||
|
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||||
|
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
||||||
|
|
||||||
|
$address = new ClientAddress();
|
||||||
|
$address->setClient($seed);
|
||||||
|
$address->setPostalCode('86100');
|
||||||
|
$address->setCity('Châtellerault');
|
||||||
|
$address->setStreet('1 rue du Test');
|
||||||
|
$address->addSite($site);
|
||||||
|
$em->persist($address);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||||
|
|
||||||
|
// Colonne « Catégories » : libelle de la categorie du client (getName()).
|
||||||
|
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
|
||||||
|
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
|
||||||
|
self::assertStringContainsString((string) $site->getName(), $flat);
|
||||||
|
}
|
||||||
|
|
||||||
public function testSirenColumnPresentWithAccountingView(): void
|
public function testSirenColumnPresentWithAccountingView(): void
|
||||||
{
|
{
|
||||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||||
|
|||||||
@@ -272,6 +272,33 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(200);
|
self::assertResponseStatusCodeSame(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void
|
||||||
|
{
|
||||||
|
// ERP-102 : /categories et /sites sont des referentiels TRANSVERSES.
|
||||||
|
// Tout role qui gere des clients (bureau / compta / commerciale) doit
|
||||||
|
// pouvoir les LISTER pour alimenter les selects de creation/filtre client,
|
||||||
|
// via la permission de lecture-referentiel dediee (catalog.categories.read_ref
|
||||||
|
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
|
||||||
|
// la permission d'administration `.view`. Usine, sans aucune permission,
|
||||||
|
// reste interdit.
|
||||||
|
foreach (['bureau', 'compta', 'commerciale'] as $role) {
|
||||||
|
$client = $this->authAs($role);
|
||||||
|
|
||||||
|
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
|
||||||
|
|
||||||
|
$client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usine : aucune permission -> reste a 403 sur les referentiels.
|
||||||
|
$usine = $this->authAs('usine');
|
||||||
|
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
|
||||||
|
$usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites');
|
||||||
|
}
|
||||||
|
|
||||||
private function authAs(string $role): Client
|
private function authAs(string $role): Client
|
||||||
{
|
{
|
||||||
return $this->authenticatedClient($role, self::PWD);
|
return $this->authenticatedClient($role, self::PWD);
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|||||||
*/
|
*/
|
||||||
final class SitesModuleTest extends KernelTestCase
|
final class SitesModuleTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
public function testPermissionsSetContainsExactlyThreeCodes(): void
|
public function testPermissionsSetContainsExactlyFourCodes(): void
|
||||||
{
|
{
|
||||||
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les
|
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les
|
||||||
// tests ou la doc, ce test casse explicitement. Si au contraire une
|
// tests ou la doc, ce test casse explicitement. Si au contraire une
|
||||||
// permission disparait (ex: bypass_scope retire par erreur), meme
|
// permission disparait (ex: bypass_scope retire par erreur), meme
|
||||||
// effet. Le set de 3 permissions est fige par ce test.
|
// effet. Le set de permissions est fige par ce test.
|
||||||
|
// `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse).
|
||||||
$codes = array_column(SitesModule::permissions(), 'code');
|
$codes = array_column(SitesModule::permissions(), 'code');
|
||||||
sort($codes);
|
sort($codes);
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['sites.bypass_scope', 'sites.manage', 'sites.view'],
|
['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'],
|
||||||
$codes,
|
$codes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
|
|||||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
|||||||
public function testPrePersistWithUser(): void
|
public function testPrePersistWithUser(): void
|
||||||
{
|
{
|
||||||
$user = $this->createStub(UserInterface::class);
|
$user = $this->createStub(UserInterface::class);
|
||||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||||
$entity = new FullAuditableFixture();
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
|||||||
|
|
||||||
public function testPrePersistWithoutUser(): void
|
public function testPrePersistWithoutUser(): void
|
||||||
{
|
{
|
||||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
|
||||||
$entity = new FullAuditableFixture();
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
@@ -59,8 +60,13 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
|||||||
|
|
||||||
public function testPreUpdate(): void
|
public function testPreUpdate(): void
|
||||||
{
|
{
|
||||||
$user = $this->createStub(UserInterface::class);
|
$user = $this->createStub(UserInterface::class);
|
||||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
// Horloge figee 1s apres le createdAt simule : updatedAt doit avancer
|
||||||
|
// de facon deterministe, sans dependre de l'heure reelle.
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber(
|
||||||
|
$this->securityReturning($user),
|
||||||
|
new MockClock(new DateTimeImmutable('2020-01-01 10:00:01')),
|
||||||
|
);
|
||||||
|
|
||||||
// On simule une entite deja persistee : createdAt fige dans le passe,
|
// On simule une entite deja persistee : createdAt fige dans le passe,
|
||||||
// createdBy positionne par une creation anterieure.
|
// createdBy positionne par une creation anterieure.
|
||||||
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
|||||||
public function testPartialEntityTimestampableOnly(): void
|
public function testPartialEntityTimestampableOnly(): void
|
||||||
{
|
{
|
||||||
$user = $this->createStub(UserInterface::class);
|
$user = $this->createStub(UserInterface::class);
|
||||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||||
$entity = new TimestampableOnlyFixture();
|
$entity = new TimestampableOnlyFixture();
|
||||||
|
|
||||||
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
||||||
|
|||||||
Reference in New Issue
Block a user