Skip to content

Commit 459eae4

Browse files
committed
Add tests for AverageRatingCalculator and ReviewRequestProcessor
Unit tests for AverageRatingCalculator covering all fallback branches (no manager, no association, no target entity, non-ReviewInterface target) and the query builder happy path. Functional tests for ReviewRequestProcessor covering the processing loop: eligible completion, ineligible rejection, future eligibility skipping, check incrementing, and cancellation after max checks.
1 parent 23c915a commit 459eae4

2 files changed

Lines changed: 347 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusReviewPlugin\Tests\Functional\Processor;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Setono\SyliusReviewPlugin\Model\ReviewRequest;
9+
use Setono\SyliusReviewPlugin\Model\ReviewRequestInterface;
10+
use Setono\SyliusReviewPlugin\Processor\ReviewRequestProcessor;
11+
use Setono\SyliusReviewPlugin\Processor\ReviewRequestProcessorInterface;
12+
use Setono\SyliusReviewPlugin\Repository\ReviewRequestRepositoryInterface;
13+
use Sylius\Component\Core\Model\OrderInterface;
14+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
15+
16+
final class ReviewRequestProcessorTest extends KernelTestCase
17+
{
18+
private EntityManagerInterface $entityManager;
19+
20+
private ReviewRequestProcessorInterface $processor;
21+
22+
private ReviewRequestRepositoryInterface $reviewRequestRepository;
23+
24+
protected function setUp(): void
25+
{
26+
self::bootKernel();
27+
28+
/** @var EntityManagerInterface $entityManager */
29+
$entityManager = self::getContainer()->get('doctrine.orm.entity_manager');
30+
$this->entityManager = $entityManager;
31+
32+
/** @var ReviewRequestProcessorInterface $processor */
33+
$processor = self::getContainer()->get(ReviewRequestProcessor::class);
34+
$this->processor = $processor;
35+
36+
/** @var ReviewRequestRepositoryInterface $reviewRequestRepository */
37+
$reviewRequestRepository = self::getContainer()->get('setono_sylius_review.repository.review_request');
38+
$this->reviewRequestRepository = $reviewRequestRepository;
39+
}
40+
41+
/** @test */
42+
public function it_completes_review_request_for_fulfilled_order(): void
43+
{
44+
$order = $this->findOrder();
45+
$order->setState(OrderInterface::STATE_FULFILLED);
46+
$order->setCheckoutCompletedAt(new \DateTimeImmutable());
47+
48+
$reviewRequest = $this->createReviewRequest($order);
49+
$id = $reviewRequest->getId();
50+
51+
$this->processor->process();
52+
53+
$reviewRequest = $this->findReviewRequest($id);
54+
55+
self::assertSame(ReviewRequestInterface::STATE_COMPLETED, $reviewRequest->getState());
56+
}
57+
58+
/** @test */
59+
public function it_does_not_complete_review_request_for_non_fulfilled_order(): void
60+
{
61+
$order = $this->findOrder();
62+
$order->setState(OrderInterface::STATE_NEW);
63+
64+
$reviewRequest = $this->createReviewRequest($order);
65+
$id = $reviewRequest->getId();
66+
67+
$this->processor->process();
68+
69+
$reviewRequest = $this->findReviewRequest($id);
70+
71+
self::assertSame(ReviewRequestInterface::STATE_PENDING, $reviewRequest->getState());
72+
self::assertSame('Order is not fulfilled', $reviewRequest->getIneligibilityReason());
73+
}
74+
75+
/** @test */
76+
public function it_does_not_process_review_requests_not_yet_due(): void
77+
{
78+
$order = $this->findOrder();
79+
$order->setState(OrderInterface::STATE_FULFILLED);
80+
$order->setCheckoutCompletedAt(new \DateTimeImmutable());
81+
82+
$reviewRequest = $this->createReviewRequest($order);
83+
$id = $reviewRequest->getId();
84+
$reviewRequest->setNextEligibilityCheckAt(new \DateTimeImmutable('+1 day'));
85+
$this->entityManager->flush();
86+
87+
$this->processor->process();
88+
89+
$reviewRequest = $this->findReviewRequest($id);
90+
91+
self::assertSame(ReviewRequestInterface::STATE_PENDING, $reviewRequest->getState());
92+
self::assertNull($reviewRequest->getIneligibilityReason());
93+
}
94+
95+
/** @test */
96+
public function it_increments_eligibility_checks_on_each_processing(): void
97+
{
98+
$order = $this->findOrder();
99+
$order->setState(OrderInterface::STATE_NEW);
100+
101+
$reviewRequest = $this->createReviewRequest($order);
102+
$id = $reviewRequest->getId();
103+
104+
self::assertSame(0, $reviewRequest->getEligibilityChecks());
105+
106+
$this->processor->process();
107+
108+
$reviewRequest = $this->findReviewRequest($id);
109+
110+
self::assertSame(1, $reviewRequest->getEligibilityChecks());
111+
}
112+
113+
/** @test */
114+
public function it_cancels_review_request_after_maximum_eligibility_checks(): void
115+
{
116+
$order = $this->findOrder();
117+
$order->setState(OrderInterface::STATE_NEW);
118+
119+
$reviewRequest = $this->createReviewRequest($order);
120+
$id = $reviewRequest->getId();
121+
122+
$maxChecks = (int) self::getContainer()->getParameter('setono_sylius_review.eligibility.maximum_checks');
123+
$reviewRequest->setEligibilityChecks($maxChecks);
124+
$this->entityManager->flush();
125+
126+
$this->processor->process();
127+
128+
$reviewRequest = $this->findReviewRequest($id);
129+
130+
self::assertSame(ReviewRequestInterface::STATE_CANCELLED, $reviewRequest->getState());
131+
}
132+
133+
private function findReviewRequest(?int $id): ReviewRequestInterface
134+
{
135+
$reviewRequest = $this->reviewRequestRepository->find($id);
136+
self::assertInstanceOf(ReviewRequestInterface::class, $reviewRequest);
137+
138+
return $reviewRequest;
139+
}
140+
141+
private function createReviewRequest(OrderInterface $order): ReviewRequest
142+
{
143+
$reviewRequest = new ReviewRequest();
144+
$reviewRequest->setOrder($order);
145+
$reviewRequest->setNextEligibilityCheckAt(new \DateTimeImmutable('-1 hour'));
146+
147+
$this->entityManager->persist($reviewRequest);
148+
$this->entityManager->flush();
149+
150+
return $reviewRequest;
151+
}
152+
153+
private function findOrder(): OrderInterface
154+
{
155+
/** @var OrderInterface|null $order */
156+
$order = $this->entityManager->getRepository(OrderInterface::class)->findOneBy([]);
157+
self::assertNotNull($order, 'No fixture order found. Make sure Sylius fixtures are loaded.');
158+
159+
return $order;
160+
}
161+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusReviewPlugin\Tests\Unit\Calculator;
6+
7+
use Doctrine\ORM\AbstractQuery;
8+
use Doctrine\ORM\EntityManagerInterface;
9+
use Doctrine\ORM\Mapping\ClassMetadata;
10+
use Doctrine\ORM\QueryBuilder;
11+
use Doctrine\Persistence\ManagerRegistry;
12+
use PHPUnit\Framework\TestCase;
13+
use Prophecy\Argument;
14+
use Prophecy\PhpUnit\ProphecyTrait;
15+
use Setono\SyliusReviewPlugin\Calculator\AverageRatingCalculator;
16+
use Sylius\Component\Review\Calculator\ReviewableRatingCalculatorInterface;
17+
use Sylius\Component\Review\Model\ReviewableInterface;
18+
use Sylius\Component\Review\Model\ReviewInterface;
19+
20+
final class AverageRatingCalculatorTest extends TestCase
21+
{
22+
use ProphecyTrait;
23+
24+
/** @test */
25+
public function it_falls_back_to_decorated_when_no_manager_exists_for_entity(): void
26+
{
27+
$reviewable = $this->prophesize(ReviewableInterface::class);
28+
29+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
30+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn(null);
31+
32+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
33+
$decorated->calculate($reviewable->reveal())->willReturn(3.5);
34+
35+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
36+
37+
self::assertSame(3.5, $calculator->calculate($reviewable->reveal()));
38+
}
39+
40+
/** @test */
41+
public function it_falls_back_to_decorated_when_entity_has_no_reviews_association(): void
42+
{
43+
$reviewable = $this->prophesize(ReviewableInterface::class);
44+
45+
$classMetadata = $this->prophesize(ClassMetadata::class);
46+
$classMetadata->hasAssociation('reviews')->willReturn(false);
47+
48+
$manager = $this->prophesize(EntityManagerInterface::class);
49+
$manager->getClassMetadata($reviewable->reveal()::class)->willReturn($classMetadata->reveal());
50+
51+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
52+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn($manager->reveal());
53+
54+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
55+
$decorated->calculate($reviewable->reveal())->willReturn(2.0);
56+
57+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
58+
59+
self::assertSame(2.0, $calculator->calculate($reviewable->reveal()));
60+
}
61+
62+
/** @test */
63+
public function it_falls_back_to_decorated_when_association_has_no_target_entity(): void
64+
{
65+
$reviewable = $this->prophesize(ReviewableInterface::class);
66+
67+
$classMetadata = $this->prophesize(ClassMetadata::class);
68+
$classMetadata->hasAssociation('reviews')->willReturn(true);
69+
$classMetadata->getAssociationMapping('reviews')->willReturn([]);
70+
71+
$manager = $this->prophesize(EntityManagerInterface::class);
72+
$manager->getClassMetadata($reviewable->reveal()::class)->willReturn($classMetadata->reveal());
73+
74+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
75+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn($manager->reveal());
76+
77+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
78+
$decorated->calculate($reviewable->reveal())->willReturn(1.5);
79+
80+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
81+
82+
self::assertSame(1.5, $calculator->calculate($reviewable->reveal()));
83+
}
84+
85+
/** @test */
86+
public function it_falls_back_to_decorated_when_target_entity_does_not_implement_review_interface(): void
87+
{
88+
$reviewable = $this->prophesize(ReviewableInterface::class);
89+
90+
$classMetadata = $this->prophesize(ClassMetadata::class);
91+
$classMetadata->hasAssociation('reviews')->willReturn(true);
92+
$classMetadata->getAssociationMapping('reviews')->willReturn([
93+
'targetEntity' => \stdClass::class,
94+
]);
95+
96+
$manager = $this->prophesize(EntityManagerInterface::class);
97+
$manager->getClassMetadata($reviewable->reveal()::class)->willReturn($classMetadata->reveal());
98+
99+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
100+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn($manager->reveal());
101+
102+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
103+
$decorated->calculate($reviewable->reveal())->willReturn(4.0);
104+
105+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
106+
107+
self::assertSame(4.0, $calculator->calculate($reviewable->reveal()));
108+
}
109+
110+
/** @test */
111+
public function it_calculates_average_rating_using_database_query(): void
112+
{
113+
$reviewable = $this->prophesize(ReviewableInterface::class);
114+
$targetEntity = $this->prophesize(ReviewInterface::class)->reveal()::class;
115+
116+
$query = $this->prophesize(AbstractQuery::class);
117+
$query->getSingleScalarResult()->willReturn('4.5');
118+
119+
$queryBuilder = $this->prophesize(QueryBuilder::class);
120+
$queryBuilder->select('AVG(r.rating)')->willReturn($queryBuilder->reveal());
121+
$queryBuilder->from($targetEntity, 'r')->willReturn($queryBuilder->reveal());
122+
$queryBuilder->andWhere('r.reviewSubject = :reviewable')->willReturn($queryBuilder->reveal());
123+
$queryBuilder->andWhere('r.status = :status')->willReturn($queryBuilder->reveal());
124+
$queryBuilder->setParameter('reviewable', $reviewable->reveal())->willReturn($queryBuilder->reveal());
125+
$queryBuilder->setParameter('status', ReviewInterface::STATUS_ACCEPTED)->willReturn($queryBuilder->reveal());
126+
$queryBuilder->getQuery()->willReturn($query->reveal());
127+
128+
$classMetadata = $this->prophesize(ClassMetadata::class);
129+
$classMetadata->hasAssociation('reviews')->willReturn(true);
130+
$classMetadata->getAssociationMapping('reviews')->willReturn([
131+
'targetEntity' => $targetEntity,
132+
]);
133+
134+
$manager = $this->prophesize(EntityManagerInterface::class);
135+
$manager->getClassMetadata($reviewable->reveal()::class)->willReturn($classMetadata->reveal());
136+
$manager->createQueryBuilder()->willReturn($queryBuilder->reveal());
137+
138+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
139+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn($manager->reveal());
140+
141+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
142+
$decorated->calculate(Argument::any())->shouldNotBeCalled();
143+
144+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
145+
146+
self::assertSame(4.5, $calculator->calculate($reviewable->reveal()));
147+
}
148+
149+
/** @test */
150+
public function it_returns_zero_when_query_returns_null(): void
151+
{
152+
$reviewable = $this->prophesize(ReviewableInterface::class);
153+
$targetEntity = $this->prophesize(ReviewInterface::class)->reveal()::class;
154+
155+
$query = $this->prophesize(AbstractQuery::class);
156+
$query->getSingleScalarResult()->willReturn(null);
157+
158+
$queryBuilder = $this->prophesize(QueryBuilder::class);
159+
$queryBuilder->select('AVG(r.rating)')->willReturn($queryBuilder->reveal());
160+
$queryBuilder->from($targetEntity, 'r')->willReturn($queryBuilder->reveal());
161+
$queryBuilder->andWhere('r.reviewSubject = :reviewable')->willReturn($queryBuilder->reveal());
162+
$queryBuilder->andWhere('r.status = :status')->willReturn($queryBuilder->reveal());
163+
$queryBuilder->setParameter('reviewable', $reviewable->reveal())->willReturn($queryBuilder->reveal());
164+
$queryBuilder->setParameter('status', ReviewInterface::STATUS_ACCEPTED)->willReturn($queryBuilder->reveal());
165+
$queryBuilder->getQuery()->willReturn($query->reveal());
166+
167+
$classMetadata = $this->prophesize(ClassMetadata::class);
168+
$classMetadata->hasAssociation('reviews')->willReturn(true);
169+
$classMetadata->getAssociationMapping('reviews')->willReturn([
170+
'targetEntity' => $targetEntity,
171+
]);
172+
173+
$manager = $this->prophesize(EntityManagerInterface::class);
174+
$manager->getClassMetadata($reviewable->reveal()::class)->willReturn($classMetadata->reveal());
175+
$manager->createQueryBuilder()->willReturn($queryBuilder->reveal());
176+
177+
$managerRegistry = $this->prophesize(ManagerRegistry::class);
178+
$managerRegistry->getManagerForClass($reviewable->reveal()::class)->willReturn($manager->reveal());
179+
180+
$decorated = $this->prophesize(ReviewableRatingCalculatorInterface::class);
181+
182+
$calculator = new AverageRatingCalculator($managerRegistry->reveal(), $decorated->reveal());
183+
184+
self::assertSame(0.0, $calculator->calculate($reviewable->reveal()));
185+
}
186+
}

0 commit comments

Comments
 (0)