diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml index 870d947..6c9e6f6 100644 --- a/.github/workflows/phpunit.yaml +++ b/.github/workflows/phpunit.yaml @@ -41,6 +41,6 @@ jobs: php -r 'echo "PHP_VERSION=", PHP_VERSION, PHP_EOL;' - name: PHPUnit - run: XDEBUG_MODE=off php -d memory_limit=128M bin/phpunit -c phpunit.xml.dist + run: XDEBUG_MODE=off php -d memory_limit=256M bin/phpunit -c phpunit.xml.dist env: DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo 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/Accounting/AccountingApiResource.php b/src/ApiResource/Accounting/AccountingApiResource.php index a61f455..5bd1c96 100644 --- a/src/ApiResource/Accounting/AccountingApiResource.php +++ b/src/ApiResource/Accounting/AccountingApiResource.php @@ -2,6 +2,7 @@ namespace App\ApiResource\Accounting; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata as API; use App\ApiResource\MoneyOutput; @@ -30,7 +31,11 @@ use AutoMapper\Attribute\MapTo; #[API\Get()] class AccountingApiResource { + /** + * Collections can be filtered by one or many known IDs, e.g: `?id[]=1&id[]=2`. + */ #[API\ApiProperty(identifier: true)] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public int $id; /** 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/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index 1467875..fed8643 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -24,7 +24,9 @@ use App\Entity\Project\ProjectVideo; use App\Entity\Territory; use App\Library\Link; use App\Mapping\Transformer\BudgetMapTransformer; +use App\Money\Totalization\TotalizedMoney; use App\State\ApiResourceStateProvider; +use App\State\Project\ProjectMoneyTotalStateProvider; use App\State\Project\ProjectStateProcessor; use App\State\Project\ProjectStateProvider; use AutoMapper\Attribute\MapFrom; @@ -40,6 +42,28 @@ use Symfony\Component\Validator\Constraints as Assert; provider: ApiResourceStateProvider::class, )] #[API\GetCollection()] +#[API\GetCollection( + uriTemplate: '/projects/money_total', + provider: ProjectMoneyTotalStateProvider::class, + output: TotalizedMoney::class, + paginationEnabled: false, + openapi: new \ApiPlatform\OpenApi\Model\Operation( + summary: 'Get money total', + description: 'Returns a single TotalizedMoney object with the money raised by the ProjectSupports of the Projects matching the same filters as the Projects collection.', + responses: [ + '200' => new \ApiPlatform\Openapi\Model\Response( + description: 'Totalized money', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/Project.TotalizedMoney', + ], + ], + ]), + ), + ], + ) +)] #[API\Post( input: ProjectCreationDto::class, processor: ProjectStateProcessor::class, diff --git a/src/ApiResource/User/UserApiResource.php b/src/ApiResource/User/UserApiResource.php index c06ce96..ba675b2 100644 --- a/src/ApiResource/User/UserApiResource.php +++ b/src/ApiResource/User/UserApiResource.php @@ -42,7 +42,9 @@ use Symfony\Component\Validator\Constraints as Assert; provider: ApiResourceStateProvider::class, processor: UserStateProcessor::class, )] -#[API\GetCollection()] +#[API\GetCollection(parameters: [ + 'email' => new API\QueryParameter(security: 'is_granted("ROLE_ADMIN")'), +])] #[API\Post(input: UserSignupDto::class, processor: UserSignupProcessor::class)] #[API\Get( provider: UserStateProvider::class, @@ -60,7 +62,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[API\Delete(securityPostDenormalize: 'is_granted("ROLE_ADMIN", previous_object)')] #[API\ApiFilter(QFilter::class, properties: [ 'q' => [ - 'email', + 'email' => 'is_granted("ROLE_ADMIN")', 'person.firstName', 'person.lastName', 'organization.legalName', @@ -89,7 +91,7 @@ class UserApiResource */ #[Assert\NotBlank()] #[Assert\Email()] - #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] + #[API\ApiFilter(SearchFilter::class, strategy: 'start')] #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] public string $email; 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/Dto/User/UserUpdationDto.php b/src/Dto/User/UserUpdationDto.php index 17190af..35a8b50 100644 --- a/src/Dto/User/UserUpdationDto.php +++ b/src/Dto/User/UserUpdationDto.php @@ -69,7 +69,7 @@ final class UserUpdationDto */ #[MapTo(User::class, transformer: RawLinksMapTransformer::class)] #[MapTo(UserApiResource::class, transformer: RawLinksMapTransformer::class)] - public array $links = []; + public array $links; /** * ISO 3166 data about the Users's location territory. 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/Filter/InArrayFilter.php b/src/Filter/InArrayFilter.php index f032d79..c75b04f 100644 --- a/src/Filter/InArrayFilter.php +++ b/src/Filter/InArrayFilter.php @@ -116,7 +116,7 @@ final class InArrayFilter extends AbstractFilter $description[$property.'[]'] = [ 'property' => $property, - 'type' => Type::BUILTIN_TYPE_ARRAY, + 'type' => Type::BUILTIN_TYPE_STRING, 'required' => false, 'is_collection' => true, ]; diff --git a/src/Filter/QFilter.php b/src/Filter/QFilter.php index ce9f3d2..813dc74 100644 --- a/src/Filter/QFilter.php +++ b/src/Filter/QFilter.php @@ -5,6 +5,7 @@ namespace App\Filter; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Symfony\Component\PropertyInfo\Type; @@ -16,10 +17,17 @@ use Symfony\Component\PropertyInfo\Type; * ``` * #[ApiFilter(QFilter::class, properties: ['q' => ['foo', 'bar.baz']])] * ``` - * To enable queries like `?q=quux` that transform into SQL like `WHERE (foo LIKE %quux% OR bar.baz LIKE %quux%)` + * To enable queries like `?q=quux` that transform into SQL like `WHERE (foo LIKE %quux% OR id IN (SELECT ... FROM bar WHERE baz LIKE %quux%))` + * + * A field can be guarded with a security expression, it is only searched when the expression is granted: + * ``` + * #[ApiFilter(QFilter::class, properties: ['q' => ['foo' => 'is_granted("ROLE_ADMIN")', 'bar.baz']])] + * ``` */ final class QFilter extends AbstractFilter { + use SecuredFilterTrait; + public const PARAMETER = 'q'; protected function filterProperty( @@ -38,30 +46,58 @@ final class QFilter extends AbstractFilter $rootAlias = $queryBuilder->getRootAliases()[0]; $parameter = $queryNameGenerator->generateParameterName(self::PARAMETER); $expr = $queryBuilder->expr(); + $metadata = $this->getClassMetadata($resourceClass); $or = $expr->orX(); - foreach ($this->properties[self::PARAMETER] as $field) { - if ( - !$this->isPropertyMapped($field, $resourceClass) - && !$this->isPropertyNested($field, $resourceClass) - ) { + $subqueryFields = []; + foreach ($this->getFields() as $field => $security) { + if (!$this->isFilteringGranted($security)) { continue; } - $alias = $rootAlias; - $fieldName = $field; - - if ($this->isPropertyNested($field, $resourceClass)) { - [$alias, $fieldName] = $this->addJoinsForNestedProperty( - $field, - $rootAlias, - $queryBuilder, - $queryNameGenerator, - $resourceClass, - Join::LEFT_JOIN - ); + if (!$this->isPropertyNested($field, $resourceClass)) { + if ($this->isPropertyMapped($field, $resourceClass)) { + $or->add($expr->like(\sprintf('%s.%s', $rootAlias, $field), ':'.$parameter)); + } + continue; } - $or->add($expr->like(sprintf('%s.%s', $alias, $fieldName), ':'.$parameter)); + + [$association, $fieldName] = \explode('.', $field, 2); + if (!\str_contains($fieldName, '.') && $this->isSubqueryable($metadata, $association)) { + $subqueryFields[$association][] = $fieldName; + continue; + } + + // ponytail: deeper paths or owning-side associations keep the slow LEFT JOIN, add a subquery shape when needed + [$alias, $fieldName] = $this->addJoinsForNestedProperty( + $field, + $rootAlias, + $queryBuilder, + $queryNameGenerator, + $resourceClass, + Join::LEFT_JOIN + ); + $or->add($expr->like(\sprintf('%s.%s', $alias, $fieldName), ':'.$parameter)); + } + + // `root.id IN (SELECT IDENTITY(a.root) FROM Assoc a WHERE ...)` lets the database scan each table once, + // instead of LIKE-ing across LEFT JOINs which forces a full joined scan (and a doubled one when paginating) + foreach ($subqueryFields as $association => $fields) { + $alias = $queryNameGenerator->generateJoinAlias($association); + $subOr = $expr->orX(); + foreach ($fields as $fieldName) { + $subOr->add($expr->like(\sprintf('%s.%s', $alias, $fieldName), ':'.$parameter)); + } + + $subquery = $queryBuilder->getEntityManager()->createQueryBuilder() + ->select(\sprintf('IDENTITY(%s.%s)', $alias, $metadata->getAssociationMappedByTargetField($association))) + ->from($metadata->getAssociationTargetClass($association), $alias) + ->where($subOr); + + $or->add($expr->in( + \sprintf('%s.%s', $rootAlias, $metadata->getSingleIdentifierFieldName()), + $subquery->getDQL() + )); } if ($or->count() === 0) { @@ -73,6 +109,36 @@ final class QFilter extends AbstractFilter ->setParameter($parameter, "%{$value}%"); } + /** + * Inverse-side associations whose owning side is a to-one can be resolved with a subquery on the owning table. + */ + private function isSubqueryable(ClassMetadata $metadata, string $association): bool + { + if (!$metadata->hasAssociation($association) || $metadata->getAssociationMapping($association)->isOwningSide()) { + return false; + } + + return $this->getClassMetadata($metadata->getAssociationTargetClass($association)) + ->isSingleValuedAssociation($metadata->getAssociationMappedByTargetField($association)); + } + + /** + * @return array The searchable fields and the security expression guarding each one + */ + private function getFields(): array + { + $fields = []; + foreach ($this->properties[self::PARAMETER] as $key => $value) { + if (\is_int($key)) { + $fields[$value] = null; + } else { + $fields[$key] = $value; + } + } + + return $fields; + } + public function getDescription(string $resourceClass): array { $description = []; @@ -85,8 +151,9 @@ final class QFilter extends AbstractFilter 'description' => \sprintf( 'Searches on %s', join(', ', array_map( - static fn($p) => "`$p`", - $this->properties[self::PARAMETER] + static fn($p, $security) => $security === null ? "`$p`" : "`$p` (secured by `$security`)", + array_keys($this->getFields()), + $this->getFields() )) ), ]; diff --git a/src/Mapping/Transformer/RawLinksMapTransformer.php b/src/Mapping/Transformer/RawLinksMapTransformer.php index 8d8ae20..c6f20e3 100644 --- a/src/Mapping/Transformer/RawLinksMapTransformer.php +++ b/src/Mapping/Transformer/RawLinksMapTransformer.php @@ -2,10 +2,16 @@ namespace App\Mapping\Transformer; +use ApiPlatform\Validator\Exception\ValidationException; use App\Library\Link; +use App\Service\Scout\InvalidUriException; +use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutResult; use App\Service\Scout\ScoutService; use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface; +use Psr\Http\Client\ClientExceptionInterface; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; class RawLinksMapTransformer implements PropertyTransformerInterface { @@ -16,12 +22,20 @@ class RawLinksMapTransformer implements PropertyTransformerInterface public function transform(mixed $value, object|array $source, array $context): mixed { $links = []; - foreach ($value as $rawLink) { - $info = $this->scoutService->get($rawLink); - + foreach ($value as $i => $rawLink) { $link = new Link(); - $link->url = $info->getUri(); - $link->rel = $this->getRel($info); + // Scouting follows redirects (e.g. Facebook to its login page), keep the URL as given + $link->url = $this->scoutService->normalizeUrl($rawLink); + $link->rel = null; + + try { + $link->rel = $this->getRel($this->scoutService->get($rawLink)); + } catch (NonCrawlableUriException) { + } catch (InvalidUriException|ClientExceptionInterface) { + throw new ValidationException(new ConstraintViolationList([ + new ConstraintViolation('Invalid or unreachable URL', null, [], null, \sprintf('links[%d]', $i), $rawLink), + ])); + } $links[] = $link; } 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/Service/Scout/ScoutService.php b/src/Service/Scout/ScoutService.php index 3c7110f..1c8b4bb 100644 --- a/src/Service/Scout/ScoutService.php +++ b/src/Service/Scout/ScoutService.php @@ -26,7 +26,14 @@ class ScoutService $this->embed = $embed; } - private function normalizeUrl(string $url): string + /** + * Adds scheme to strings without one. + * + * @param string $url A URL-like string + * + * @return string The input URL with an https scheme + */ + public function normalizeUrl(string $url): string { $nurl = $url; 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/ProjectMoneyTotalStateProvider.php b/src/State/Project/ProjectMoneyTotalStateProvider.php new file mode 100644 index 0000000..ad9aa77 --- /dev/null +++ b/src/State/Project/ProjectMoneyTotalStateProvider.php @@ -0,0 +1,45 @@ +queryBuilderExtractor + ->getQueryBuilder($operation, $context, fn($e) => !$e instanceof PaginationExtension) + ->resetDQLPart('orderBy') + ->select('IDENTITY(o.accounting)'); + + // Projects are matched in a subquery so to-many filter joins (categories, themes) can't duplicate accountings + $accountings = $projects->getEntityManager()->createQueryBuilder() + ->select('a.balance.amount AS amount') + ->addSelect('a.balance.currency AS currency') + ->from(Accounting::class, 'a') + ->where(\sprintf('a.id IN (%s)', $projects->getDQL())) + ->andWhere('a.balance.amount IS NOT NULL') + ->andWhere('a.balance.currency IS NOT NULL') + ->setParameters($projects->getParameters()); + + return $this->moneyArrayTotalizer->totalize($accountings->getQuery()->getArrayResult()); + } +} 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/Accounting/GetAllTest.php b/tests/Entity/Accounting/GetAllTest.php new file mode 100644 index 0000000..d97fcf2 --- /dev/null +++ b/tests/Entity/Accounting/GetAllTest.php @@ -0,0 +1,34 @@ + [ + 'handle' => "user_$i", + 'email' => "user_$i@example.com", + ]); + $ids = [$a->getAccounting()->getId(), $b->getAccounting()->getId()]; + + $data = $this->request('GET', '/v4/accountings', [ + 'query' => ['id' => $ids], + ], Response::HTTP_OK)->toArray(); + + $this->assertEqualsCanonicalizing($ids, \array_column($data['member'], 'id')); + } +} 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/Entity/ProjectApi/MoneyTotalTest.php b/tests/Entity/ProjectApi/MoneyTotalTest.php new file mode 100644 index 0000000..d99d2c3 --- /dev/null +++ b/tests/Entity/ProjectApi/MoneyTotalTest.php @@ -0,0 +1,117 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + + $this->owner = UserFactory::new()->withoutPersisting()->create()->_real(); + + $this->entityManager->persist($this->owner); + $this->entityManager->flush(); + } + + private function createCategory(string $name): Category + { + $category = new Category(); + $category->setName($name); + + $this->entityManager->persist($category); + $this->entityManager->flush(); + + return $category; + } + + /** + * @param Category[] $categories + */ + private function createProjectWithBalance( + int $amount, + ProjectStatus $status = ProjectStatus::InCampaign, + array $categories = [], + ): void { + /** @var Project */ + $project = ProjectFactory::new([ + 'owner' => $this->owner, + 'status' => $status, + ])->withoutPersisting()->create()->_real(); + + foreach ($categories as $category) { + $project->addCategory($category); + } + + $project->getAccounting()->setBalance(new EmbeddableMoney($amount, 'EUR')); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + } + + public function testEmpty(): void + { + $this->request('GET', self::URI, [], Response::HTTP_OK); + + $this->assertJsonContains(['amount' => 0, 'length' => 0]); + } + + public function testUnfilteredReturnsTotal(): void + { + $this->createProjectWithBalance(1000); + $this->createProjectWithBalance(2500); + + $this->request('GET', self::URI, [], Response::HTTP_OK); + + $this->assertJsonContains(['amount' => 3500, 'currency' => 'EUR', 'length' => 2]); + } + + public function testFilteredByStatus(): void + { + $this->createProjectWithBalance(1000, ProjectStatus::InCampaign); + $this->createProjectWithBalance(2500, ProjectStatus::InDraft); + + $this->request('GET', self::URI, [ + 'query' => ['status' => ProjectStatus::InCampaign->value], + ], Response::HTTP_OK); + + $this->assertJsonContains(['amount' => 1000, 'length' => 1]); + } + + public function testFilteredByManyCategoriesDoesNotDuplicate(): void + { + $a = $this->createCategory('a'); + $b = $this->createCategory('b'); + $this->createProjectWithBalance(1000, categories: [$a, $b]); + + $this->request('GET', self::URI, [ + 'query' => ['categories' => [$a->getId(), $b->getId()]], + ], Response::HTTP_OK); + + $this->assertJsonContains(['amount' => 1000, 'length' => 1]); + } +} diff --git a/tests/Entity/UserApi/SearchTest.php b/tests/Entity/UserApi/SearchTest.php new file mode 100644 index 0000000..90dbc9f --- /dev/null +++ b/tests/Entity/UserApi/SearchTest.php @@ -0,0 +1,72 @@ + 'by_email', 'email' => 'quixote@example.com']); + + UserFactory::new() + ->afterInstantiate(fn(User $u) => $u->getPerson()->setLastName('Quixote')) + ->create(['handle' => 'by_person', 'email' => 'person@example.com']); + + UserFactory::new() + ->afterInstantiate(fn(User $u) => $u->setOrganization(Organization::for($u)->setLegalName('Quixote S.L.'))) + ->create(['handle' => 'by_organization', 'email' => 'organization@example.com']); + + UserFactory::createOne(['handle' => 'unrelated', 'email' => 'sancho@example.com']); + $admin = UserFactory::createOne(['handle' => 'admin', 'email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN']]); + + $data = $this->request('GET', '/v4/users?q=quixote', [ + 'headers' => $this->withAuthHeader($admin), + ], Response::HTTP_OK)->toArray(); + + $handles = array_column($data['member'], 'handle'); + sort($handles); + + $this->assertSame(['by_email', 'by_organization', 'by_person'], $handles); + $this->assertSame(3, $data['totalItems']); + + // email is only searched for admins + $data = $this->request('GET', '/v4/users?q=quixote', [], Response::HTTP_OK)->toArray(); + + $handles = array_column($data['member'], 'handle'); + sort($handles); + + $this->assertSame(['by_organization', 'by_person'], $handles); + } + + public function testEmailFilterMatchesByPrefix(): void + { + UserFactory::createOne(['handle' => 'quixote', 'email' => 'quixote@example.com']); + $admin = UserFactory::createOne(['handle' => 'admin', 'email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN']]); + $options = ['headers' => $this->withAuthHeader($admin)]; + + $data = $this->request('GET', '/v4/users?email=QUIX', $options, Response::HTTP_OK)->toArray(); + $this->assertSame(['quixote'], array_column($data['member'], 'handle')); + + $data = $this->request('GET', '/v4/users?email=example.com', $options, Response::HTTP_OK)->toArray(); + $this->assertSame(0, $data['totalItems']); + } + + public function testEmailFilterIsAdminOnly(): void + { + $this->request('GET', '/v4/users?email=quix', [], Response::HTTP_FORBIDDEN); + $this->request('GET', '/v4/users?email[]=quix', [], Response::HTTP_FORBIDDEN); + } +} diff --git a/tests/Entity/UserApi/UpdateTest.php b/tests/Entity/UserApi/UpdateTest.php index 6098403..d4e06d8 100644 --- a/tests/Entity/UserApi/UpdateTest.php +++ b/tests/Entity/UserApi/UpdateTest.php @@ -17,10 +17,15 @@ use function Zenstruck\Foundry\Persistence\persist; class UpdateTest extends ApiTestCase { - use ResetDatabase; use Factories; + use ResetDatabase; use RequestingTestTrait; + public function setUp(): void + { + self::bootKernel(); + } + public function testUpdateProfileFields(): void { $user = TestUser::get(); @@ -50,6 +55,48 @@ class UpdateTest extends ApiTestCase $this->assertCount(1, $data['preferredThemes']); } + public function testUpdateLinksKeepsGivenUrl(): void + { + $user = TestUser::get(); + + // http://github.com redirects to https://github.com/ + $this->request('PATCH', \sprintf('/v4/users/%d', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['links' => ['http://github.com']], + ], Response::HTTP_OK); + + $this->assertJsonContains(['links' => [['url' => 'http://github.com']]]); + } + + public function testUpdateWithoutLinksKeepsLinks(): void + { + $user = TestUser::get(); + $uri = \sprintf('/v4/users/%d', $user->getId()); + + $this->request('PATCH', $uri, [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['links' => ['https://github.com']], + ], Response::HTTP_OK); + + $this->request('PATCH', $uri, [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['description' => 'test'], + ], Response::HTTP_OK); + + $data = $this->request('GET', $uri, ['headers' => $this->withAuthHeader($user)])->toArray(); + $this->assertCount(1, $data['links']); + } + + public function testUpdateInvalidLinks(): void + { + $user = TestUser::get(); + + $this->request('PATCH', \sprintf('/v4/users/%d', $user->getId()), [ + 'headers' => $this->withAuthHeader($user), + 'json' => ['links' => ['not a url']], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + public function testPrivateFieldsHiddenFromOthers(): void { $user = TestUser::get(); 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())); + } +}