diff --git a/config/services.yaml b/config/services.yaml index e40a324..e06624d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 } diff --git a/src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php b/src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php index 44b05f0..d58c843 100644 --- a/src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php +++ b/src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php @@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Address; interface AddressRepositoryInterface { public function findById(int $id): ?Address; + + /** + * @param array $criteria + * @param null|array $orderBy + * + * @return Address[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; } diff --git a/src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php b/src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php index 94fa3d0..793a580 100644 --- a/src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php +++ b/src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php @@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\CommercialReport; interface CommercialReportRepositoryInterface { public function findById(int $id): ?CommercialReport; + + /** + * @param array $criteria + * @param null|array $orderBy + * + * @return CommercialReport[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; } diff --git a/src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php b/src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php index 0f28c4d..6b8ef1e 100644 --- a/src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php +++ b/src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php @@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Contact; interface ContactRepositoryInterface { public function findById(int $id): ?Contact; + + /** + * @param array $criteria + * @param null|array $orderBy + * + * @return Contact[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; } diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/CreateAddressTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateAddressTool.php new file mode 100644 index 0000000..9bef3b5 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateAddressTool.php @@ -0,0 +1,95 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/CreateCommercialReportTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateCommercialReportTool.php new file mode 100644 index 0000000..aa04696 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateCommercialReportTool.php @@ -0,0 +1,103 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/CreateContactTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateContactTool.php new file mode 100644 index 0000000..134e923 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateContactTool.php @@ -0,0 +1,90 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/CreatePrestataireTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/CreatePrestataireTool.php new file mode 100644 index 0000000..6295a30 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/CreatePrestataireTool.php @@ -0,0 +1,43 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteAddressTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteAddressTool.php new file mode 100644 index 0000000..c552d05 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteAddressTool.php @@ -0,0 +1,41 @@ +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)]); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteCommercialReportTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteCommercialReportTool.php new file mode 100644 index 0000000..46818ba --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteCommercialReportTool.php @@ -0,0 +1,41 @@ +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)]); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteContactTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteContactTool.php new file mode 100644 index 0000000..b7595b0 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteContactTool.php @@ -0,0 +1,41 @@ +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)]); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/DeletePrestataireTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/DeletePrestataireTool.php new file mode 100644 index 0000000..5d8d861 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/DeletePrestataireTool.php @@ -0,0 +1,42 @@ +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)]); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetAddressTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetAddressTool.php new file mode 100644 index 0000000..0923cfd --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetAddressTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetClientTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetClientTool.php index 676c1b0..8a7f8b8 100644 --- a/src/Module/Directory/Infrastructure/Mcp/Tool/GetClientTool.php +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetClientTool.php @@ -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); } } diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetCommercialReportTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetCommercialReportTool.php new file mode 100644 index 0000000..2ecac13 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetCommercialReportTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetContactTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetContactTool.php new file mode 100644 index 0000000..d484250 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetContactTool.php @@ -0,0 +1,37 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetPrestataireTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetPrestataireTool.php new file mode 100644 index 0000000..0193c7e --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetPrestataireTool.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php index 06db797..2a2d13c 100644 --- a/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php @@ -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); } } diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ListAddressesTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ListAddressesTool.php new file mode 100644 index 0000000..8d3d9dc --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ListAddressesTool.php @@ -0,0 +1,54 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ListCommercialReportsTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ListCommercialReportsTool.php new file mode 100644 index 0000000..d548e3a --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ListCommercialReportsTool.php @@ -0,0 +1,54 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ListContactsTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ListContactsTool.php new file mode 100644 index 0000000..4fdc3a0 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ListContactsTool.php @@ -0,0 +1,54 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ListPrestatairesTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ListPrestatairesTool.php new file mode 100644 index 0000000..8ed55f2 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ListPrestatairesTool.php @@ -0,0 +1,34 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateAddressTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateAddressTool.php new file mode 100644 index 0000000..598239d --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateAddressTool.php @@ -0,0 +1,73 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateCommercialReportTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateCommercialReportTool.php new file mode 100644 index 0000000..64ffa87 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateCommercialReportTool.php @@ -0,0 +1,73 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateContactTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateContactTool.php new file mode 100644 index 0000000..737ae7c --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateContactTool.php @@ -0,0 +1,70 @@ +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)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/UpdatePrestataireTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdatePrestataireTool.php new file mode 100644 index 0000000..2845b6a --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdatePrestataireTool.php @@ -0,0 +1,59 @@ +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)); + } +} diff --git a/src/Shared/Infrastructure/Mcp/Serializer.php b/src/Shared/Infrastructure/Mcp/Serializer.php index bbe17f6..21e3fbc 100644 --- a/src/Shared/Infrastructure/Mcp/Serializer.php +++ b/src/Shared/Infrastructure/Mcp/Serializer.php @@ -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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 */ diff --git a/tests/Functional/Mcp/Directory/AddressLifecycleTest.php b/tests/Functional/Mcp/Directory/AddressLifecycleTest.php new file mode 100644 index 0000000..bcd0222 --- /dev/null +++ b/tests/Functional/Mcp/Directory/AddressLifecycleTest.php @@ -0,0 +1,229 @@ +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(), + ); + } +} diff --git a/tests/Functional/Mcp/Directory/CommercialReportLifecycleTest.php b/tests/Functional/Mcp/Directory/CommercialReportLifecycleTest.php new file mode 100644 index 0000000..74c6282 --- /dev/null +++ b/tests/Functional/Mcp/Directory/CommercialReportLifecycleTest.php @@ -0,0 +1,265 @@ +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(), + ); + } +} diff --git a/tests/Functional/Mcp/Directory/ContactLifecycleTest.php b/tests/Functional/Mcp/Directory/ContactLifecycleTest.php new file mode 100644 index 0000000..5099aeb --- /dev/null +++ b/tests/Functional/Mcp/Directory/ContactLifecycleTest.php @@ -0,0 +1,216 @@ +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(), + ); + } +} diff --git a/tests/Functional/Mcp/Directory/PrestataireLifecycleTest.php b/tests/Functional/Mcp/Directory/PrestataireLifecycleTest.php new file mode 100644 index 0000000..7219afd --- /dev/null +++ b/tests/Functional/Mcp/Directory/PrestataireLifecycleTest.php @@ -0,0 +1,183 @@ +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), + ); + } +}