Merge branch 'main' into feat/dtf-api

This commit is contained in:
2026-11-05 15:53:27 +01:00
24 changed files with 756 additions and 63 deletions
+1 -1
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261026131134 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add default flag to addresses';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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;
/**
+9 -2
View File
@@ -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;
}
@@ -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,
+5 -3
View File
@@ -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;
+6
View File
@@ -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;
}
+6
View File
@@ -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;
}
+1 -1
View File
@@ -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.
+15
View File
@@ -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;
}
}
+1 -1
View File
@@ -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,
];
+88 -21
View File
@@ -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<string, ?string> 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()
))
),
];
@@ -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;
}
+22 -23
View File
@@ -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();
}
}
+8 -1
View File
@@ -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;
+13 -2
View File
@@ -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;
@@ -0,0 +1,45 @@
<?php
namespace App\State\Project;
use ApiPlatform\Doctrine\Orm\Extension\PaginationExtension;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Accounting\Accounting;
use App\Money\Totalization\TotalizedMoney;
use App\Money\Totalization\Totalizer\MoneyArrayTotalizer;
use App\State\QueryBuilderExtractor;
/**
* Totalizes the money raised by a filtered Project collection.
*
* Reuses the same ApiFilters declared on the Project collection by re-applying every registered
* ORM collection extension, then totalizes the balance of the Accountings of the matching Projects.
*/
class ProjectMoneyTotalStateProvider implements ProviderInterface
{
public function __construct(
private QueryBuilderExtractor $queryBuilderExtractor,
private MoneyArrayTotalizer $moneyArrayTotalizer,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TotalizedMoney
{
$projects = $this->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());
}
}
@@ -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
),
]));
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Tests\Entity\Accounting;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Factory\User\UserFactory;
use App\Tests\Traits\RequestingTestTrait;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class GetAllTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
use RequestingTestTrait;
public function testFilterById(): void
{
self::bootKernel();
[$a, $b] = UserFactory::createMany(3, fn(int $i) => [
'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'));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Tests\Entity;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Address;
use App\Entity\User\User;
use App\Factory\User\UserFactory;
use App\Tests\Fixtures\TestUser;
use App\Tests\Traits\RequestingTestTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class AddressApiTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
use RequestingTestTrait;
private const BASE_URI = '/v4/addresses';
public function setUp(): void
{
self::bootKernel();
}
public function testDeleteOtherUserAddressIsForbidden(): void
{
$other = UserFactory::new(['handle' => '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);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Tests\Entity\ProjectApi;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Category;
use App\Entity\EmbeddableMoney;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectStatus;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use App\Factory\User\UserFactory;
use App\Tests\Traits\RequestingTestTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class MoneyTotalTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
use RequestingTestTrait;
private const URI = '/v4/projects/money_total';
private EntityManagerInterface $entityManager;
private User $owner;
protected function setUp(): void
{
parent::setUp();
$this->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]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Tests\Entity\UserApi;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\User\Organization;
use App\Entity\User\User;
use App\Factory\User\UserFactory;
use App\Tests\Traits\RequestingTestTrait;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class SearchTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
use RequestingTestTrait;
public function testSearchesAcrossOwnAndRelatedFields(): void
{
UserFactory::createOne(['handle' => '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);
}
}
+48 -1
View File
@@ -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();
@@ -0,0 +1,31 @@
<?php
namespace App\Tests\State;
use App\Entity\Address;
use App\Entity\User\User;
use App\State\Project\RewardClaimStateProcessor;
use PHPUnit\Framework\TestCase;
class RewardClaimAddressOwnerTest extends TestCase
{
public function testNullAddressIsValid(): void
{
$this->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()));
}
}