diff --git a/migrations/Version20261026131134.php b/migrations/Version20261026131134.php new file mode 100644 index 0000000..1a7528a --- /dev/null +++ b/migrations/Version20261026131134.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE address ADD is_default TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE address DROP is_default'); + } +} diff --git a/src/ApiResource/AddressApiResource.php b/src/ApiResource/AddressApiResource.php index c418287..15a62f5 100644 --- a/src/ApiResource/AddressApiResource.php +++ b/src/ApiResource/AddressApiResource.php @@ -27,12 +27,12 @@ use App\State\ApiResourceStateProvider; input: AddressCreationDto::class, securityPostDenormalize: 'is_granted("USER_EDIT", object.user)' )] -#[API\Get] +#[API\Get(security: 'is_granted("ADDRESS_VIEW", object)')] #[API\Patch( input: AddressUpdationDto::class, securityPostDenormalize: 'is_granted("ADDRESS_EDIT", previous_object)' )] -#[API\Delete()] +#[API\Delete(security: 'is_granted("ADDRESS_EDIT", object)')] class AddressApiResource { #[API\ApiProperty(identifier: true, writable: false)] @@ -87,4 +87,11 @@ class AddressApiResource #[API\ApiProperty(security: 'is_granted("ADDRESS_VIEW", object)')] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public string $country; + + /** + * Whether this is the User's default Address. Each User has at most one default Address, + * setting it to `true` on one Address unsets it on the rest of the User's Addresses. + */ + #[API\ApiProperty(security: 'is_granted("ADDRESS_VIEW", object)')] + public bool $isDefault = false; } diff --git a/src/Dto/AddressCreationDto.php b/src/Dto/AddressCreationDto.php index bd5b76c..26123ae 100644 --- a/src/Dto/AddressCreationDto.php +++ b/src/Dto/AddressCreationDto.php @@ -60,4 +60,10 @@ class AddressCreationDto #[Assert\NotBlank()] #[Assert\Country()] public string $country; + + /** + * Whether this is the User's default Address. + * Setting it to `true` unsets it on the rest of the User's Addresses. + */ + public ?bool $isDefault = null; } diff --git a/src/Dto/AddressUpdationDto.php b/src/Dto/AddressUpdationDto.php index dde11aa..ce2e9d1 100644 --- a/src/Dto/AddressUpdationDto.php +++ b/src/Dto/AddressUpdationDto.php @@ -48,4 +48,10 @@ class AddressUpdationDto */ #[Assert\Country()] public ?string $country = null; + + /** + * Whether this is the User's default Address. + * Setting it to `true` unsets it on the rest of the User's Addresses. + */ + public ?bool $isDefault = null; } diff --git a/src/Entity/Address.php b/src/Entity/Address.php index b648e9b..092757f 100644 --- a/src/Entity/Address.php +++ b/src/Entity/Address.php @@ -42,6 +42,9 @@ class Address #[ORM\JoinColumn(nullable: false)] private ?User $user = null; + #[ORM\Column(options: ['default' => false])] + private bool $isDefault = false; + public function getId(): ?int { return $this->id; @@ -142,4 +145,16 @@ class Address return $this; } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): static + { + $this->isDefault = $isDefault; + + return $this; + } } diff --git a/src/Repository/AddressRepository.php b/src/Repository/AddressRepository.php index 46ee39e..0464533 100644 --- a/src/Repository/AddressRepository.php +++ b/src/Repository/AddressRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\Address; +use App\Entity\User\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -16,28 +17,26 @@ class AddressRepository extends ServiceEntityRepository parent::__construct($registry, Address::class); } - // /** - // * @return Address[] Returns an array of Address objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('a') - // ->andWhere('a.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('a.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } + /** + * Unsets the default flag on every Address of the given User except the given one. + */ + public function unsetDefaultsOfUser(User $user, ?Address $except): void + { + $query = $this->createQueryBuilder('a') + ->update() + ->set('a.isDefault', ':false') + ->where('a.user = :user') + ->setParameter('false', false) + ->setParameter('user', $user) + ; - // public function findOneBySomeField($value): ?Address - // { - // return $this->createQueryBuilder('a') - // ->andWhere('a.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + if ($except?->getId() !== null) { + $query + ->andWhere('a.id != :except') + ->setParameter('except', $except->getId()) + ; + } + + $query->getQuery()->execute(); + } } diff --git a/src/State/AddressStateProcessor.php b/src/State/AddressStateProcessor.php index 87de01f..4e39e4f 100644 --- a/src/State/AddressStateProcessor.php +++ b/src/State/AddressStateProcessor.php @@ -10,16 +10,20 @@ use App\Dto\AddressCreationDto; use App\Dto\AddressUpdationDto; use App\Entity\Address; use App\Mapping\AutoMapper; +use App\Repository\AddressRepository; +use Doctrine\ORM\EntityManagerInterface; class AddressStateProcessor implements ProcessorInterface { public function __construct( private AutoMapper $autoMapper, private EntityStateProcessor $entityStateProcessor, + private AddressRepository $addressRepository, + private EntityManagerInterface $entityManager, ) {} /** - * @param AddressCreationDto|AddressUpdationDto $data + * @param AddressCreationDto|AddressUpdationDto|AddressApiResource $data * * @return AddressApiResource */ @@ -28,10 +32,17 @@ class AddressStateProcessor implements ProcessorInterface $address = match ($data::class) { AddressCreationDto::class => $this->getAddressFromCreation($data), AddressUpdationDto::class => $this->getAddressFromUpdate($data, $context), + AddressApiResource::class => $this->autoMapper->map($data, Address::class), default => throw new \InvalidArgumentException('Unsupported input for Address resource'), }; - $address = $this->entityStateProcessor->process($address, $operation, $uriVariables, $context); + $address = $this->entityManager->wrapInTransaction(function () use ($address, $operation, $uriVariables, $context) { + if ($address->isDefault()) { + $this->addressRepository->unsetDefaultsOfUser($address->getUser(), $address); + } + + return $this->entityStateProcessor->process($address, $operation, $uriVariables, $context); + }); if ($operation instanceof DeleteOperationInterface) { return; diff --git a/src/State/Project/RewardClaimStateProcessor.php b/src/State/Project/RewardClaimStateProcessor.php index a906cbd..1b41f1f 100644 --- a/src/State/Project/RewardClaimStateProcessor.php +++ b/src/State/Project/RewardClaimStateProcessor.php @@ -5,10 +5,13 @@ namespace App\State\Project; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Validator\Exception\ValidationException; use App\ApiResource\Project\RewardClaimApiResource; use App\Dto\RewardClaimCreationDto; use App\Dto\RewardClaimUpdationDto; +use App\Entity\Address; use App\Entity\Project\RewardClaim; +use App\Entity\User\User; use App\Mapping\AutoMapper; use App\Repository\Project\RewardClaimRepository; use App\Security\Voter\AccountingVoter; @@ -16,9 +19,13 @@ use App\State\EntityStateProcessor; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; class RewardClaimStateProcessor implements ProcessorInterface { + private const ADDRESS_NOT_OWNED_MESSAGE = 'The Address does not belong to the User who owns the claim.'; + public function __construct( private AutoMapper $autoMapper, private RewardClaimRepository $rewardClaimRepository, @@ -34,7 +41,7 @@ class RewardClaimStateProcessor implements ProcessorInterface public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { $claim = match ($data::class) { - RewardClaimUpdationDto::class => $this->getClaimFromUpdate($context), + RewardClaimUpdationDto::class => $this->getClaimFromUpdate($data, $context), RewardClaimCreationDto::class => $this->getClaimFromCreate($data), }; @@ -51,13 +58,28 @@ class RewardClaimStateProcessor implements ProcessorInterface return $this->autoMapper->map($claim, $data); } - private function getClaimFromUpdate(array $context): RewardClaim + /** + * An Address can only be used in a claim by the User who owns that claim. + */ + public static function isAddressOwnedBy(?Address $address, ?User $owner): bool + { + return $address === null || $address->getUser() === $owner; + } + + private function getClaimFromUpdate(RewardClaimUpdationDto $data, array $context): RewardClaim { $claim = $this->rewardClaimRepository->find($context['previous_data']->id); if (!$claim) { throw new NotFoundHttpException(); } + if (isset($data->address)) { + /** @var Address */ + $address = $this->autoMapper->map($data->address, Address::class); + + $this->validateAddressOwner($address, $claim->getOwner(), $data); + } + return $claim; } @@ -73,8 +95,28 @@ class RewardClaimStateProcessor implements ProcessorInterface $owner = $origin->getUser(); + $this->validateAddressOwner($claim->getAddress(), $owner, $data); + $claim->setOwner($owner); return $claim; } + + private function validateAddressOwner(?Address $address, ?User $owner, object $data): void + { + if (self::isAddressOwnedBy($address, $owner)) { + return; + } + + throw new ValidationException(new ConstraintViolationList([ + new ConstraintViolation( + self::ADDRESS_NOT_OWNED_MESSAGE, + self::ADDRESS_NOT_OWNED_MESSAGE, + [], + $data, + 'address', + $data->address + ), + ])); + } } diff --git a/tests/Entity/AddressApiTest.php b/tests/Entity/AddressApiTest.php new file mode 100644 index 0000000..2358a2a --- /dev/null +++ b/tests/Entity/AddressApiTest.php @@ -0,0 +1,116 @@ + 'other_user', 'email' => 'other@example.com'])->create()->_real(); + $address = $this->createAddress($other); + + $this->request('DELETE', $this->getUri($address), [ + 'headers' => $this->withAuthHeader(TestUser::get()), + ], Response::HTTP_FORBIDDEN); + } + + public function testDeleteOwnAddress(): void + { + $user = TestUser::get(); + $address = $this->createAddress($user); + + $this->request('DELETE', $this->getUri($address), [ + 'headers' => $this->withAuthHeader($user), + ], Response::HTTP_NO_CONTENT); + } + + public function testGetOtherUserAddressIsForbidden(): void + { + $other = UserFactory::new(['handle' => 'other_user', 'email' => 'other@example.com'])->create()->_real(); + $address = $this->createAddress($other); + + $this->request('GET', $this->getUri($address), [ + 'headers' => $this->withAuthHeader(TestUser::get()), + ], Response::HTTP_FORBIDDEN); + } + + public function testSetDefaultUnsetsOtherDefaultsOfUser(): void + { + $user = TestUser::get(); + $userId = $user->getId(); + $auth = $this->withAuthHeader($user); + $other = UserFactory::new(['handle' => 'other_user', 'email' => 'other@example.com'])->create()->_real(); + + $previous = $this->createAddress($user, true); + $address = $this->createAddress($user); + $othersDefault = $this->createAddress($other, true); + + $response = $this->request('PATCH', $this->getUri($address), [ + 'headers' => $auth, + 'json' => ['isDefault' => true], + ], Response::HTTP_OK); + + $this->assertTrue($response->toArray()['isDefault']); + + $response = $this->request('GET', \sprintf('%s?user=/v4/users/%d', self::BASE_URI, $userId), [ + 'headers' => $auth, + ], Response::HTTP_OK); + + $defaults = \array_column($response->toArray()['member'], 'isDefault', 'id'); + $this->assertEquals([$previous->getId() => false, $address->getId() => true], $defaults); + + $this->getEntityManager()->clear(); + $this->assertTrue($this->getEntityManager()->find(Address::class, $othersDefault->getId())->isDefault()); + } + + private function createAddress(User $user, bool $isDefault = false): Address + { + $address = (new Address()) + ->setUser($this->getEntityManager()->getReference(User::class, $user->getId())) + ->setFirstName('Test') + ->setLastName('User') + ->setLine1('Carrer Major 1') + ->setCity('Barcelona') + ->setPostCode('08001') + ->setCountry('ES') + ->setIsDefault($isDefault) + ; + + $this->getEntityManager()->persist($address); + $this->getEntityManager()->flush(); + + return $address; + } + + private function getUri(Address $address): string + { + return \sprintf('%s/%d', self::BASE_URI, $address->getId()); + } + + private function getEntityManager(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } +} diff --git a/tests/State/RewardClaimAddressOwnerTest.php b/tests/State/RewardClaimAddressOwnerTest.php new file mode 100644 index 0000000..5d3f1cf --- /dev/null +++ b/tests/State/RewardClaimAddressOwnerTest.php @@ -0,0 +1,31 @@ +assertTrue(RewardClaimStateProcessor::isAddressOwnedBy(null, new User())); + } + + public function testAddressOfClaimOwnerIsValid(): void + { + $owner = new User(); + $address = (new Address())->setUser($owner); + + $this->assertTrue(RewardClaimStateProcessor::isAddressOwnedBy($address, $owner)); + } + + public function testAddressOfOtherUserIsNotValid(): void + { + $address = (new Address())->setUser(new User()); + + $this->assertFalse(RewardClaimStateProcessor::isAddressOwnedBy($address, new User())); + } +}