Merge branch 'main' into feat/dtf-api
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user