Compare commits

..

3 Commits

Author SHA1 Message Date
matthieu aad949c10c test(directory) : tests fonctionnels MCP pour Prestataire/Contact/Address/CommercialReport
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Couvre les 20 nouveaux outils MCP Directory (5 par entite : create/get/list/
update/delete) avec un focus sur les guards et invariants :
- exactly-one-parent (Contact/Address/CommercialReport)
- ROLE_ADMIN
- ISO 3166 alpha-2 + normalisation uppercase (Address)
- enum ReportType + defaults note/today + parsing date (CommercialReport)
- author auto-rempli par CommercialReportAuthorListener (token storage)
- collections vides dans get-prestataire enrichi
- ordre DESC sur occurredAt pour list-commercial-reports
- delete renvoie null apres em.clear()

38 tests / 105 assertions. Suite complete passe a 217/217.
2026-06-24 21:08:06 +02:00
matthieu ad029f5c7d chore(directory) : ferme contrats Repository (findBy) + bindings DI MCP Directory
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m34s
Plumbing complementaire des outils MCP ajoutes en 99626b8 :
- declare findBy() sur Address/Contact/CommercialReport RepositoryInterface
  (Prestataire l'avait deja) pour exposer la methode au contrat DDD
- bindings explicites des 4 repos dans services.yaml (cohrence avec
  Client/Prospect, meme si Symfony auto-alias l'interface vers l'unique
  implementation)
2026-06-24 20:53:17 +02:00
matthieu 99626b89da feat(mcp) : outils MCP Directory pour prestataires, contacts, adresses et rapports
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 59s
Ajoute 20 nouveaux outils MCP pour permettre à Claude (ou tout client MCP) de
remplir un dossier client / prospect / prestataire complet — onglets
Information, Contact, Adresse et Rapport — sans passer par l'UI.

Entités couvertes (CRUD complet, 5 outils chacune) :
- Prestataire : create / update / get / list / delete
- Contact : create / update / get / list / delete
- Address : create / update / get / list / delete
- CommercialReport : create / update / get / list / delete

Détails :
- Contact / Address / CommercialReport doivent être rattachés à exactement
  un parent parmi clientId, prospectId, prestataireId (validation côté tool).
- get-client, get-prospect et get-prestataire renvoient désormais un payload
  enrichi avec la liste de leurs contacts, adresses et rapports liés : un
  seul appel pour reconstruire l'onglet entier.
- Pour CommercialReport, le type (note / call / meeting / email) et la date
  occurredAt sont validés ; l'auteur est rempli automatiquement par le
  listener existant.
- Sécurité : ROLE_ADMIN aligné sur les autres outils MCP de Directory (pas
  de migration vers les permissions RBAC fines pour rester cohérent).

Plumbing :
- Repositories Contact / Address / CommercialReport : ajout de findBy() sur
  les interfaces (l'implémentation Doctrine l'a déjà via ServiceEntityRepository).
- Bindings interface -> implémentation Doctrine ajoutés dans services.yaml
  pour Prestataire / Contact / Address / CommercialReport.
- Sérialiseur partagé étendu : prestataire / contact / address /
  commercialReport / reportDocument.

Vérification : 86 outils MCP exposés au total (66 avant + 20 ajoutés), test
end-to-end via le transport HTTP (create-prestataire + create-contact +
create-address + create-commercial-report + get-prestataire renvoyant le
dossier complet). Suite PHPUnit verte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 20:36:46 +02:00
31 changed files with 2201 additions and 4 deletions
+8
View File
@@ -113,6 +113,14 @@ services:
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Address;
interface AddressRepositoryInterface
{
public function findById(int $id): ?Address;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Address[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\CommercialReport;
interface CommercialReportRepositoryInterface
{
public function findById(int $id): ?CommercialReport;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return CommercialReport[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Contact;
interface ContactRepositoryInterface
{
public function findById(int $id): ?Contact;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Contact[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'create-address',
description: 'Create an address (admin) attached to exactly one of clientId / prospectId / prestataireId. Country defaults to FR (ISO 3166 alpha-2).'
)]
class CreateAddressTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $label = null,
?string $street = null,
?string $streetComplement = null,
?string $postalCode = null,
?string $city = null,
?string $country = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$address = new Address();
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$address->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$address->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$address->setPrestataire($prestataire);
}
$address->setLabel($label);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setPostalCode($postalCode);
$address->setCity($city);
if (null !== $country) {
if (2 !== strlen($country)) {
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
}
$address->setCountry(strtoupper($country));
}
$this->entityManager->persist($address);
$this->entityManager->flush();
return json_encode(Serializer::address($address));
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'create-commercial-report',
description: 'Create a commercial report (admin) attached to exactly one of clientId / prospectId / prestataireId. Type defaults to "note". Allowed types: note, call, meeting, email. Date defaults to today if omitted.'
)]
class CreateCommercialReportTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
string $subject,
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $body = null,
?string $occurredAt = null,
?string $type = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$report = new CommercialReport();
$report->setSubject($subject);
$report->setBody($body);
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$report->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$report->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$report->setPrestataire($prestataire);
}
try {
$date = null === $occurredAt
? new DateTimeImmutable('today')
: new DateTimeImmutable($occurredAt);
} catch (Exception $e) {
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
}
$report->setOccurredAt($date);
if (null !== $type) {
$typeEnum = ReportType::tryFrom($type);
if (null === $typeEnum) {
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
}
$report->setType($typeEnum);
}
$this->entityManager->persist($report);
$this->entityManager->flush();
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'create-contact',
description: 'Create a contact (admin) attached to exactly one of clientId / prospectId / prestataireId. All fields except the parent are optional.'
)]
class CreateContactTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
?string $firstName = null,
?string $lastName = null,
?string $jobTitle = null,
?string $email = null,
?string $phonePrimary = null,
?string $phoneSecondary = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (1 !== count($parents)) {
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
}
$contact = new Contact();
if (null !== $clientId) {
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$contact->setClient($client);
}
if (null !== $prospectId) {
$prospect = $this->prospectRepository->findById($prospectId);
if (null === $prospect) {
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
}
$contact->setProspect($prospect);
}
if (null !== $prestataireId) {
$prestataire = $this->prestataireRepository->findById($prestataireId);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
}
$contact->setPrestataire($prestataire);
}
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setJobTitle($jobTitle);
$contact->setEmail($email);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$this->entityManager->persist($contact);
$this->entityManager->flush();
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-prestataire', description: 'Create a prestataire / service provider (admin). Only name is required.')]
class CreatePrestataireTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
string $name,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = new Prestataire();
$prestataire->setName($name);
$prestataire->setEmail($email);
$prestataire->setPhone($phone);
$prestataire->setWebsite($website);
$this->entityManager->persist($prestataire);
$this->entityManager->flush();
return json_encode(Serializer::prestataire($prestataire));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-address', description: 'Delete an address (admin).')]
class DeleteAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
$this->entityManager->remove($address);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Address #%d deleted.', $id)]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-commercial-report', description: 'Delete a commercial report (admin). Cascade removes its attached documents.')]
class DeleteCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
$this->entityManager->remove($report);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('CommercialReport #%d deleted.', $id)]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-contact', description: 'Delete a contact (admin).')]
class DeleteContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
$this->entityManager->remove($contact);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Contact #%d deleted.', $id)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-prestataire', description: 'Delete a prestataire (admin). Cascade removes its contacts, addresses and reports.')]
class DeletePrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
$name = $prestataire->getName();
$this->entityManager->remove($prestataire);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Prestataire "%s" deleted.', $name)]);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-address', description: 'Get an address by ID.')]
class GetAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
return json_encode(Serializer::address($address));
}
}
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
#[McpTool(name: 'get-client', description: 'Get a client by ID, with its linked contacts, addresses, and commercial reports.')]
class GetClientTool
{
public function __construct(
private readonly ClientRepositoryInterface $clientRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
@@ -32,6 +38,20 @@ class GetClientTool
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
return json_encode(Serializer::client($client));
$payload = Serializer::client($client);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['client' => $client], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['client' => $client], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['client' => $client], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-commercial-report', description: 'Get a commercial report by ID, including its attached documents.')]
class GetCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-contact', description: 'Get a contact by ID.')]
class GetContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-prestataire', description: 'Get a prestataire by ID, with its linked contacts, addresses, and commercial reports.')]
class GetPrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
$payload = Serializer::prestataire($prestataire);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['prestataire' => $prestataire], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['prestataire' => $prestataire], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['prestataire' => $prestataire], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID, with its linked contacts, addresses, and commercial reports.')]
class GetProspectTool
{
public function __construct(
private readonly ProspectRepositoryInterface $prospectRepository,
private readonly ContactRepositoryInterface $contactRepository,
private readonly AddressRepositoryInterface $addressRepository,
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
@@ -32,6 +38,20 @@ class GetProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
}
return json_encode(Serializer::prospect($prospect));
$payload = Serializer::prospect($prospect);
$payload['contacts'] = array_map(
fn ($c) => Serializer::contact($c),
$this->contactRepository->findBy(['prospect' => $prospect], ['lastName' => 'ASC'])
);
$payload['addresses'] = array_map(
fn ($a) => Serializer::address($a),
$this->addressRepository->findBy(['prospect' => $prospect], ['id' => 'ASC'])
);
$payload['reports'] = array_map(
fn ($r) => Serializer::commercialReport($r),
$this->reportRepository->findBy(['prospect' => $prospect], ['occurredAt' => 'DESC'])
);
return json_encode($payload);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-addresses',
description: 'List addresses, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
)]
class ListAddressesTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$addresses = $this->addressRepository->findBy($criteria, ['id' => 'ASC']);
return json_encode(array_map(fn ($a) => Serializer::address($a), $addresses));
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-commercial-reports',
description: 'List commercial reports, optionally filtered by parent (at most one of clientId / prospectId / prestataireId). Returns reports ordered by occurredAt DESC.'
)]
class ListCommercialReportsTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$reports = $this->reportRepository->findBy($criteria, ['occurredAt' => 'DESC']);
return json_encode(array_map(fn ($r) => Serializer::commercialReport($r), $reports));
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-contacts',
description: 'List contacts, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
)]
class ListContactsTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $clientId = null,
?int $prospectId = null,
?int $prestataireId = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
if (count($filters) > 1) {
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
}
$criteria = [];
if (null !== $clientId) {
$criteria['client'] = $clientId;
}
if (null !== $prospectId) {
$criteria['prospect'] = $prospectId;
}
if (null !== $prestataireId) {
$criteria['prestataire'] = $prestataireId;
}
$contacts = $this->contactRepository->findBy($criteria, ['lastName' => 'ASC']);
return json_encode(array_map(fn ($c) => Serializer::contact($c), $contacts));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-prestataires', description: 'List all prestataires with their IDs, names, and emails. Use this to discover valid prestataire IDs.')]
class ListPrestatairesTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataires = $this->prestataireRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($p) => [
'id' => $p->getId(),
'name' => $p->getName(),
'email' => $p->getEmail(),
], $prestataires));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'update-address',
description: 'Update an address (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
)]
class UpdateAddressTool
{
public function __construct(
private readonly AddressRepositoryInterface $addressRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $label = null,
?string $street = null,
?string $streetComplement = null,
?string $postalCode = null,
?string $city = null,
?string $country = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$address = $this->addressRepository->findById($id);
if (null === $address) {
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
}
if (null !== $label) {
$address->setLabel($label);
}
if (null !== $street) {
$address->setStreet($street);
}
if (null !== $streetComplement) {
$address->setStreetComplement($streetComplement);
}
if (null !== $postalCode) {
$address->setPostalCode($postalCode);
}
if (null !== $city) {
$address->setCity($city);
}
if (null !== $country) {
if (2 !== strlen($country)) {
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
}
$address->setCountry(strtoupper($country));
}
$this->entityManager->flush();
return json_encode(Serializer::address($address));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'update-commercial-report',
description: 'Update a commercial report (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
)]
class UpdateCommercialReportTool
{
public function __construct(
private readonly CommercialReportRepositoryInterface $reportRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $subject = null,
?string $body = null,
?string $occurredAt = null,
?string $type = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$report = $this->reportRepository->findById($id);
if (null === $report) {
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
}
if (null !== $subject) {
$report->setSubject($subject);
}
if (null !== $body) {
$report->setBody($body);
}
if (null !== $occurredAt) {
try {
$report->setOccurredAt(new DateTimeImmutable($occurredAt));
} catch (Exception $e) {
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
}
}
if (null !== $type) {
$typeEnum = ReportType::tryFrom($type);
if (null === $typeEnum) {
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
}
$report->setType($typeEnum);
}
$this->entityManager->flush();
return json_encode(Serializer::commercialReport($report));
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(
name: 'update-contact',
description: 'Update a contact (admin). Only provided fields change. The parent (client/prospect/prestataire) is immutable — delete then recreate to re-attach.'
)]
class UpdateContactTool
{
public function __construct(
private readonly ContactRepositoryInterface $contactRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $firstName = null,
?string $lastName = null,
?string $jobTitle = null,
?string $email = null,
?string $phonePrimary = null,
?string $phoneSecondary = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$contact = $this->contactRepository->findById($id);
if (null === $contact) {
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
}
if (null !== $firstName) {
$contact->setFirstName($firstName);
}
if (null !== $lastName) {
$contact->setLastName($lastName);
}
if (null !== $jobTitle) {
$contact->setJobTitle($jobTitle);
}
if (null !== $email) {
$contact->setEmail($email);
}
if (null !== $phonePrimary) {
$contact->setPhonePrimary($phonePrimary);
}
if (null !== $phoneSecondary) {
$contact->setPhoneSecondary($phoneSecondary);
}
$this->entityManager->flush();
return json_encode(Serializer::contact($contact));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-prestataire', description: 'Update a prestataire (admin). Only provided fields change.')]
class UpdatePrestataireTool
{
public function __construct(
private readonly PrestataireRepositoryInterface $prestataireRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$prestataire = $this->prestataireRepository->findById($id);
if (null === $prestataire) {
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
}
if (null !== $name) {
$prestataire->setName($name);
}
if (null !== $email) {
$prestataire->setEmail($email);
}
if (null !== $phone) {
$prestataire->setPhone($phone);
}
if (null !== $website) {
$prestataire->setWebsite($website);
}
$this->entityManager->flush();
return json_encode(Serializer::prestataire($prestataire));
}
}
@@ -8,8 +8,13 @@ use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Entity\ReportDocument;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
@@ -374,6 +379,98 @@ final class Serializer
];
}
/**
* @return array<string, mixed>
*/
public static function prestataire(Prestataire $p): array
{
return [
'id' => $p->getId(),
'name' => $p->getName(),
'email' => $p->getEmail(),
'phone' => $p->getPhone(),
'website' => $p->getWebsite(),
];
}
/**
* @return array<string, mixed>
*/
public static function contact(Contact $c): array
{
return [
'id' => $c->getId(),
'firstName' => $c->getFirstName(),
'lastName' => $c->getLastName(),
'jobTitle' => $c->getJobTitle(),
'email' => $c->getEmail(),
'phonePrimary' => $c->getPhonePrimary(),
'phoneSecondary' => $c->getPhoneSecondary(),
'clientId' => $c->getClient()?->getId(),
'prospectId' => $c->getProspect()?->getId(),
'prestataireId' => $c->getPrestataire()?->getId(),
];
}
/**
* @return array<string, mixed>
*/
public static function address(Address $a): array
{
return [
'id' => $a->getId(),
'label' => $a->getLabel(),
'street' => $a->getStreet(),
'streetComplement' => $a->getStreetComplement(),
'postalCode' => $a->getPostalCode(),
'city' => $a->getCity(),
'country' => $a->getCountry(),
'clientId' => $a->getClient()?->getId(),
'prospectId' => $a->getProspect()?->getId(),
'prestataireId' => $a->getPrestataire()?->getId(),
];
}
/**
* @return array<string, mixed>
*/
public static function reportDocument(ReportDocument $d): array
{
return [
'id' => $d->getId(),
'originalName' => $d->getOriginalName(),
'mimeType' => $d->getMimeType(),
'size' => $d->getSize(),
'createdAt' => $d->getCreatedAt()?->format('c'),
'uploadedBy' => self::user($d->getUploadedBy()),
];
}
/**
* @return array<string, mixed>
*/
public static function commercialReport(CommercialReport $r): array
{
return [
'id' => $r->getId(),
'subject' => $r->getSubject(),
'body' => $r->getBody(),
'occurredAt' => $r->getOccurredAt()?->format('Y-m-d'),
'type' => $r->getType()->value,
'typeLabel' => $r->getType()->label(),
'author' => self::user($r->getAuthor()),
'clientId' => $r->getClient()?->getId(),
'prospectId' => $r->getProspect()?->getId(),
'prestataireId' => $r->getPrestataire()?->getId(),
'documents' => array_map(
fn (ReportDocument $d) => self::reportDocument($d),
$r->getDocuments()->toArray()
),
'createdAt' => $r->getCreatedAt()?->format('c'),
'updatedAt' => $r->getUpdatedAt()?->format('c'),
];
}
/**
* @return array<string, mixed>
*/
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp\Directory;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateAddressTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteAddressTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\GetAddressTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\ListAddressesTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateAddressTool;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @internal
*/
class AddressLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $admin;
private Client $client;
private Prospect $prospect;
private Prestataire $prestataire;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->admin = new User();
$this->admin->setUsername('mcp-address-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
$this->client = new Client();
$this->client->setName('Test Client '.uniqid());
$this->em->persist($this->client);
$this->prospect = new Prospect();
$this->prospect->setCompany('Test Prospect '.uniqid());
$this->em->persist($this->prospect);
$this->prestataire = new Prestataire();
$this->prestataire->setName('Test Prestataire '.uniqid());
$this->em->persist($this->prestataire);
$this->em->flush();
}
public function testCreateRequiresExactlyOneParent(): void
{
try {
($this->createTool())(null, null, null, 'Home');
self::fail('Expected error when no parent provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
try {
($this->createTool())($this->client->getId(), null, $this->prestataire->getId(), 'Dup');
self::fail('Expected error when two parents provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
}
public function testCreateCountryDefaultsToFRWhenOmitted(): void
{
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ'), true);
self::assertSame('FR', $data['country']);
}
public function testCreateRejectsNonIso3166Country(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'France');
}
public function testCreateNormalizesCountryToUppercase(): void
{
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'be'), true);
self::assertSame('BE', $data['country']);
}
public function testCreateOnEachParentWorks(): void
{
$clientAddr = json_decode(($this->createTool())($this->client->getId(), null, null, 'CHQ'), true);
self::assertSame($this->client->getId(), $clientAddr['clientId']);
self::assertNull($clientAddr['prospectId']);
$prospectAddr = json_decode(($this->createTool())(null, $this->prospect->getId(), null, 'PHQ'), true);
self::assertSame($this->prospect->getId(), $prospectAddr['prospectId']);
$prestAddr = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'XHQ'), true);
self::assertSame($this->prestataire->getId(), $prestAddr['prestataireId']);
}
public function testGetReturnsAddress(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Office', '1 rue X', null, '75001', 'Paris', 'FR'), true);
$data = json_decode(($this->getTool())((int) $created['id']), true);
self::assertSame('Office', $data['label']);
self::assertSame('1 rue X', $data['street']);
self::assertSame('75001', $data['postalCode']);
self::assertSame('Paris', $data['city']);
self::assertSame('FR', $data['country']);
}
public function testListFilteredByClient(): void
{
($this->createTool())($this->client->getId(), null, null, 'A');
($this->createTool())($this->client->getId(), null, null, 'B');
($this->createTool())(null, null, $this->prestataire->getId(), 'Z');
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
self::assertCount(2, $data);
self::assertSame('A', $data[0]['label']);
self::assertSame('B', $data[1]['label']);
}
public function testUpdateRejectsNonIso3166Country(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'X'), true);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
($this->updateTool())((int) $created['id'], null, null, null, null, null, 'Belgium');
}
public function testUpdateOnlyTouchesProvidedFields(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Old', '1 rue X', null, '75001', 'Paris', 'FR'), true);
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, '75002', null, 'be'), true);
self::assertSame('New', $data['label']); // changed
self::assertSame('1 rue X', $data['street']); // unchanged
self::assertSame('75002', $data['postalCode']); // changed
self::assertSame('Paris', $data['city']); // unchanged
self::assertSame('BE', $data['country']); // changed + uppercased
}
public function testDeleteRemovesAddress(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
$id = (int) $created['id'];
$data = json_decode(($this->deleteTool())($id), true);
self::assertTrue($data['success']);
$this->em->clear();
self::assertNull(self::getContainer()->get(AddressRepositoryInterface::class)->findById($id));
}
private function securityFor(bool $admin = true): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn($admin);
$security->method('getUser')->willReturn($admin ? $this->admin : null);
return $security;
}
private function createTool(): CreateAddressTool
{
$c = self::getContainer();
return new CreateAddressTool(
$this->em,
$c->get(ClientRepositoryInterface::class),
$c->get(ProspectRepositoryInterface::class),
$c->get(PrestataireRepositoryInterface::class),
$this->securityFor(),
);
}
private function getTool(): GetAddressTool
{
return new GetAddressTool(
self::getContainer()->get(AddressRepositoryInterface::class),
$this->securityFor(),
);
}
private function listTool(): ListAddressesTool
{
return new ListAddressesTool(
self::getContainer()->get(AddressRepositoryInterface::class),
$this->securityFor(),
);
}
private function updateTool(): UpdateAddressTool
{
return new UpdateAddressTool(
self::getContainer()->get(AddressRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
private function deleteTool(): DeleteAddressTool
{
return new DeleteAddressTool(
self::getContainer()->get(AddressRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
}
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp\Directory;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateCommercialReportTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteCommercialReportTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\GetCommercialReportTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\ListCommercialReportsTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateCommercialReportTool;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
/**
* @internal
*/
class CommercialReportLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $admin;
private Client $client;
private Prospect $prospect;
private Prestataire $prestataire;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->admin = new User();
$this->admin->setUsername('mcp-report-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
$this->client = new Client();
$this->client->setName('Test Client '.uniqid());
$this->em->persist($this->client);
$this->prospect = new Prospect();
$this->prospect->setCompany('Test Prospect '.uniqid());
$this->em->persist($this->prospect);
$this->prestataire = new Prestataire();
$this->prestataire->setName('Test Prestataire '.uniqid());
$this->em->persist($this->prestataire);
$this->em->flush();
}
public function testCreateRequiresExactlyOneParent(): void
{
try {
($this->createTool())('subject', null, null, null);
self::fail('Expected error when no parent provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
try {
($this->createTool())('subject', $this->client->getId(), null, $this->prestataire->getId());
self::fail('Expected error when two parents provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
}
public function testCreateRejectsInvalidType(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid type "lunch". Allowed: note, call, meeting, email.');
($this->createTool())('Lunch at noon', $this->client->getId(), null, null, null, null, 'lunch');
}
public function testCreateAcceptsAllValidTypes(): void
{
foreach (['note', 'call', 'meeting', 'email'] as $type) {
$data = json_decode(
($this->createTool())('subject', $this->client->getId(), null, null, null, '2026-01-15', $type),
true,
);
self::assertSame($type, $data['type']);
}
}
public function testCreateDefaultsTypeToNote(): void
{
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
self::assertSame(ReportType::Note->value, $data['type']);
}
public function testCreateRejectsInvalidDate(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid occurredAt "not-a-date"');
($this->createTool())('subject', $this->client->getId(), null, null, null, 'not-a-date');
}
public function testCreateDefaultsOccurredAtToToday(): void
{
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
self::assertSame(new DateTimeImmutable('today')->format('Y-m-d'), $data['occurredAt']);
}
public function testCreateAutoFillsAuthorFromCurrentUser(): void
{
$this->loginAdmin();
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
self::assertNotNull($data['author']);
self::assertSame($this->admin->getId(), $data['author']['id']);
self::assertSame($this->admin->getUsername(), $data['author']['username']);
}
public function testGetReturnsReport(): void
{
$created = json_decode(
($this->createTool())('My subject', $this->prestataire->getId() ? null : null, null, $this->prestataire->getId(), 'body text', '2026-03-01', 'meeting'),
true,
);
$data = json_decode(($this->getTool())((int) $created['id']), true);
self::assertSame('My subject', $data['subject']);
self::assertSame('body text', $data['body']);
self::assertSame('2026-03-01', $data['occurredAt']);
self::assertSame('meeting', $data['type']);
self::assertSame($this->prestataire->getId(), $data['prestataireId']);
self::assertSame([], $data['documents']);
}
public function testListOrderedByOccurredAtDesc(): void
{
($this->createTool())('oldest', $this->client->getId(), null, null, null, '2026-01-01');
($this->createTool())('newest', $this->client->getId(), null, null, null, '2026-12-01');
($this->createTool())('middle', $this->client->getId(), null, null, null, '2026-06-15');
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
self::assertCount(3, $data);
self::assertSame('newest', $data[0]['subject']);
self::assertSame('middle', $data[1]['subject']);
self::assertSame('oldest', $data[2]['subject']);
}
public function testListRejectsMultipleFilters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
}
public function testUpdateChangesTypeAndDate(): void
{
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null, null, '2026-01-01', 'note'), true);
$data = json_decode(($this->updateTool())((int) $created['id'], 'new subject', null, '2026-02-02', 'call'), true);
self::assertSame('new subject', $data['subject']);
self::assertSame('2026-02-02', $data['occurredAt']);
self::assertSame('call', $data['type']);
}
public function testUpdateRejectsInvalidType(): void
{
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null), true);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid type "lunch"');
($this->updateTool())((int) $created['id'], null, null, null, 'lunch');
}
public function testDeleteRemovesReport(): void
{
$created = json_decode(($this->createTool())('Bye', $this->client->getId(), null, null), true);
$id = (int) $created['id'];
$data = json_decode(($this->deleteTool())($id), true);
self::assertTrue($data['success']);
$this->em->clear();
self::assertNull(self::getContainer()->get(CommercialReportRepositoryInterface::class)->findById($id));
}
private function loginAdmin(): void
{
$token = new UsernamePasswordToken($this->admin, 'main', $this->admin->getRoles());
self::getContainer()->get(TokenStorageInterface::class)->setToken($token);
}
private function securityFor(bool $admin = true): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn($admin);
$security->method('getUser')->willReturn($admin ? $this->admin : null);
return $security;
}
private function createTool(): CreateCommercialReportTool
{
$c = self::getContainer();
return new CreateCommercialReportTool(
$this->em,
$c->get(ClientRepositoryInterface::class),
$c->get(ProspectRepositoryInterface::class),
$c->get(PrestataireRepositoryInterface::class),
$this->securityFor(),
);
}
private function getTool(): GetCommercialReportTool
{
return new GetCommercialReportTool(
self::getContainer()->get(CommercialReportRepositoryInterface::class),
$this->securityFor(),
);
}
private function listTool(): ListCommercialReportsTool
{
return new ListCommercialReportsTool(
self::getContainer()->get(CommercialReportRepositoryInterface::class),
$this->securityFor(),
);
}
private function updateTool(): UpdateCommercialReportTool
{
return new UpdateCommercialReportTool(
self::getContainer()->get(CommercialReportRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
private function deleteTool(): DeleteCommercialReportTool
{
return new DeleteCommercialReportTool(
self::getContainer()->get(CommercialReportRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
}
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp\Directory;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateContactTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteContactTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\GetContactTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\ListContactsTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateContactTool;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @internal
*/
class ContactLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $admin;
private Client $client;
private Prospect $prospect;
private Prestataire $prestataire;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->admin = new User();
$this->admin->setUsername('mcp-contact-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
$this->client = new Client();
$this->client->setName('Test Client '.uniqid());
$this->em->persist($this->client);
$this->prospect = new Prospect();
$this->prospect->setCompany('Test Prospect '.uniqid());
$this->em->persist($this->prospect);
$this->prestataire = new Prestataire();
$this->prestataire->setName('Test Prestataire '.uniqid());
$this->em->persist($this->prestataire);
$this->em->flush();
}
public function testCreateRequiresExactlyOneParent(): void
{
try {
($this->createTool())(null, null, null, 'Anon');
self::fail('Expected error when no parent provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
try {
($this->createTool())($this->client->getId(), $this->prospect->getId(), null, 'Dup');
self::fail('Expected error when two parents provided.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
}
}
public function testCreateWithUnknownClientThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Client with ID 999999 not found.');
($this->createTool())(999999, null, null, 'Anon');
}
public function testCreateOnEachParentWorks(): void
{
foreach (
[
['clientId', $this->client->getId()],
['prospectId', $this->prospect->getId()],
['prestataireId', $this->prestataire->getId()],
] as [$field, $id]
) {
$args = [null, null, null, 'John', 'Doe-'.$field, 'CTO', 'john@x.test'];
$idx = ['clientId' => 0, 'prospectId' => 1, 'prestataireId' => 2][$field];
$args[$idx] = $id;
$data = json_decode(($this->createTool())(...$args), true);
self::assertSame('Doe-'.$field, $data['lastName']);
self::assertSame($id, $data[$field]);
}
}
public function testGetReturnsContact(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Jane', 'Smith'), true);
$data = json_decode(($this->getTool())((int) $created['id']), true);
self::assertSame('Jane', $data['firstName']);
self::assertSame('Smith', $data['lastName']);
self::assertSame($this->client->getId(), $data['clientId']);
}
public function testListFilteredByPrestataire(): void
{
($this->createTool())(null, null, $this->prestataire->getId(), 'A', 'A-Last');
($this->createTool())(null, null, $this->prestataire->getId(), 'B', 'B-Last');
($this->createTool())($this->client->getId(), null, null, 'Z', 'Z-Last');
$data = json_decode(($this->listTool())(null, null, $this->prestataire->getId()), true);
self::assertCount(2, $data);
self::assertSame('A-Last', $data[0]['lastName']);
self::assertSame('B-Last', $data[1]['lastName']);
}
public function testListRejectsMultipleFilters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
}
public function testUpdateOnlyTouchesProvidedFields(): void
{
$created = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'Old', 'Last', 'CTO', 'old@x.test'), true);
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, 'new@x.test'), true);
self::assertSame('New', $data['firstName']); // changed
self::assertSame('Last', $data['lastName']); // unchanged
self::assertSame('CTO', $data['jobTitle']); // unchanged
self::assertSame('new@x.test', $data['email']); // changed
}
public function testDeleteRemovesContact(): void
{
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
$id = (int) $created['id'];
$data = json_decode(($this->deleteTool())($id), true);
self::assertTrue($data['success']);
$this->em->clear();
self::assertNull(self::getContainer()->get(ContactRepositoryInterface::class)->findById($id));
}
private function securityFor(bool $admin = true): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn($admin);
$security->method('getUser')->willReturn($admin ? $this->admin : null);
return $security;
}
private function createTool(): CreateContactTool
{
$c = self::getContainer();
return new CreateContactTool(
$this->em,
$c->get(ClientRepositoryInterface::class),
$c->get(ProspectRepositoryInterface::class),
$c->get(PrestataireRepositoryInterface::class),
$this->securityFor(),
);
}
private function getTool(): GetContactTool
{
return new GetContactTool(
self::getContainer()->get(ContactRepositoryInterface::class),
$this->securityFor(),
);
}
private function listTool(): ListContactsTool
{
return new ListContactsTool(
self::getContainer()->get(ContactRepositoryInterface::class),
$this->securityFor(),
);
}
private function updateTool(): UpdateContactTool
{
return new UpdateContactTool(
self::getContainer()->get(ContactRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
private function deleteTool(): DeleteContactTool
{
return new DeleteContactTool(
self::getContainer()->get(ContactRepositoryInterface::class),
$this->em,
$this->securityFor(),
);
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp\Directory;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use App\Module\Directory\Infrastructure\Mcp\Tool\CreatePrestataireTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\DeletePrestataireTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\GetPrestataireTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\ListPrestatairesTool;
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdatePrestataireTool;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @internal
*/
class PrestataireLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $admin;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->admin = new User();
$this->admin->setUsername('mcp-prest-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
$this->em->flush();
}
public function testCreatePersistsAllFields(): void
{
$json = ($this->createTool(admin: true))('ACME Cleaning', 'contact@acme.example', '+33100000000', 'https://acme.example');
$data = json_decode($json, true);
self::assertIsInt($data['id']);
self::assertSame('ACME Cleaning', $data['name']);
self::assertSame('contact@acme.example', $data['email']);
self::assertSame('+33100000000', $data['phone']);
self::assertSame('https://acme.example', $data['website']);
}
public function testCreateRequiresAdmin(): void
{
$this->expectException(AccessDeniedException::class);
($this->createTool(admin: false))('Should not pass');
}
public function testGetReturnsEmptyCollectionsWhenNoChildren(): void
{
$created = json_decode(($this->createTool(admin: true))('Lonely Prest'), true);
$json = ($this->getTool(admin: true))((int) $created['id']);
$data = json_decode($json, true);
self::assertSame($created['id'], $data['id']);
self::assertSame([], $data['contacts']);
self::assertSame([], $data['addresses']);
self::assertSame([], $data['reports']);
}
public function testGetUnknownIdThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Prestataire with ID 999999 not found.');
($this->getTool(admin: true))(999999);
}
public function testUpdateOnlyTouchesProvidedFields(): void
{
$created = json_decode(($this->createTool(admin: true))('Before', 'before@x.test', '+33000000000', 'https://before.test'), true);
$json = ($this->updateTool(admin: true))((int) $created['id'], null, 'after@x.test', null, null);
$data = json_decode($json, true);
self::assertSame('Before', $data['name']); // unchanged
self::assertSame('after@x.test', $data['email']); // changed
self::assertSame('+33000000000', $data['phone']); // unchanged
self::assertSame('https://before.test', $data['website']); // unchanged
}
public function testListReturnsAllPrestatairesOrderedByName(): void
{
// Unique prefix isolates this test from data leaked by prior PHPUnit
// runs (DAMA rollback is not active in this project).
$prefix = 'list-test-'.uniqid().'-';
($this->createTool(admin: true))($prefix.'Zeta');
($this->createTool(admin: true))($prefix.'Alpha');
($this->createTool(admin: true))($prefix.'Mu');
$data = json_decode(($this->listTool(admin: true))(), true);
$names = array_values(array_filter(
array_column($data, 'name'),
fn ($n) => str_starts_with((string) $n, $prefix),
));
self::assertSame([$prefix.'Alpha', $prefix.'Mu', $prefix.'Zeta'], $names);
}
public function testDeleteRemovesPrestataire(): void
{
$created = json_decode(($this->createTool(admin: true))('To be removed'), true);
$id = (int) $created['id'];
$json = ($this->deleteTool(admin: true))($id);
$data = json_decode($json, true);
self::assertTrue($data['success']);
self::assertStringContainsString('"To be removed"', $data['message']);
$this->em->clear();
self::assertNull(self::getContainer()->get(PrestataireRepositoryInterface::class)->findById($id));
}
private function securityFor(bool $admin): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn($admin);
$security->method('getUser')->willReturn($admin ? $this->admin : null);
return $security;
}
private function createTool(bool $admin): CreatePrestataireTool
{
return new CreatePrestataireTool(
$this->em,
$this->securityFor($admin),
);
}
private function getTool(bool $admin): GetPrestataireTool
{
$c = self::getContainer();
return new GetPrestataireTool(
$c->get(PrestataireRepositoryInterface::class),
$c->get(ContactRepositoryInterface::class),
$c->get(AddressRepositoryInterface::class),
$c->get(CommercialReportRepositoryInterface::class),
$this->securityFor($admin),
);
}
private function updateTool(bool $admin): UpdatePrestataireTool
{
return new UpdatePrestataireTool(
self::getContainer()->get(PrestataireRepositoryInterface::class),
$this->em,
$this->securityFor($admin),
);
}
private function listTool(bool $admin): ListPrestatairesTool
{
return new ListPrestatairesTool(
self::getContainer()->get(PrestataireRepositoryInterface::class),
$this->securityFor($admin),
);
}
private function deleteTool(bool $admin): DeletePrestataireTool
{
return new DeletePrestataireTool(
self::getContainer()->get(PrestataireRepositoryInterface::class),
$this->em,
$this->securityFor($admin),
);
}
}