diff --git a/migrations/Version20261006114714.php b/migrations/Version20261006114714.php new file mode 100644 index 0000000..0ade74e --- /dev/null +++ b/migrations/Version20261006114714.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE review (id INT AUTO_INCREMENT NOT NULL, project_id INT NOT NULL, reviewer_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_794381C6166D1F9C (project_id), INDEX IDX_794381C670574616 (reviewer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE review_area (id INT AUTO_INCREMENT NOT NULL, review_id INT NOT NULL, title VARCHAR(255) NOT NULL, summary LONGTEXT DEFAULT NULL, risk VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_352070703E2E969B (review_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE review_comment (id INT AUTO_INCREMENT NOT NULL, author_id INT DEFAULT NULL, area_id INT NOT NULL, body LONGTEXT NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_F9AE69BF675F31B (author_id), INDEX IDX_F9AE69BBD0F409C (area_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C670574616 FOREIGN KEY (reviewer_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE review_area ADD CONSTRAINT FK_352070703E2E969B FOREIGN KEY (review_id) REFERENCES review (id)'); + $this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BF675F31B FOREIGN KEY (author_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BBD0F409C FOREIGN KEY (area_id) REFERENCES review_area (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C6166D1F9C'); + $this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C670574616'); + $this->addSql('ALTER TABLE review_area DROP FOREIGN KEY FK_352070703E2E969B'); + $this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BF675F31B'); + $this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BBD0F409C'); + $this->addSql('DROP TABLE review'); + $this->addSql('DROP TABLE review_area'); + $this->addSql('DROP TABLE review_comment'); + } +} diff --git a/src/ApiResource/Project/ReviewApiResource.php b/src/ApiResource/Project/ReviewApiResource.php new file mode 100644 index 0000000..82cc879 --- /dev/null +++ b/src/ApiResource/Project/ReviewApiResource.php @@ -0,0 +1,57 @@ + + */ + #[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'project', cascade: ['persist'])] + private Collection $reviews; + + /* * A list of URLs provided by the Project owner.\ * e.g: social profiles, project website. * @@ -179,6 +185,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized $this->supports = new ArrayCollection(); $this->categories = new ArrayCollection(); $this->collaborations = new ArrayCollection(); + $this->reviews = new ArrayCollection(); } public function getId(): ?int @@ -378,6 +385,10 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this->status; } + /** + * DO NOT call this method unless you know what you are doing: + * Instead use `ProjectService::transition` or you WILL miss side-effects. + */ public function setStatus(ProjectStatus $status): static { $this->status = $status; @@ -594,6 +605,36 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized } /** + * @return Collection + */ + public function getReviews(): Collection + { + return $this->reviews; + } + + public function addReview(Review $review): static + { + if (!$this->reviews->contains($review)) { + $this->reviews->add($review); + $review->setProject($this); + } + + return $this; + } + + public function removeReview(Review $review): static + { + if ($this->reviews->removeElement($review)) { + // set the owning side to null (unless already changed) + if ($review->getProject() === $this) { + $review->setProject(null); + } + } + + return $this; + } + + /* * @return Link[] */ public function getLinks(): ?array diff --git a/src/Entity/Project/Review.php b/src/Entity/Project/Review.php new file mode 100644 index 0000000..14b81e3 --- /dev/null +++ b/src/Entity/Project/Review.php @@ -0,0 +1,124 @@ + + */ + #[ORM\OneToMany(targetEntity: ReviewArea::class, mappedBy: 'review', cascade: ['persist'])] + private Collection $areas; + + public function __construct() + { + $this->areas = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getReviewer(): ?User + { + return $this->reviewer; + } + + public function isReviewedBy(UserInterface $user): bool + { + return $this->reviewer?->getUserIdentifier() === $user->getUserIdentifier(); + } + + public function setReviewer(?User $reviewer): static + { + $this->reviewer = $reviewer; + + return $this; + } + + public function getType(): ?ReviewType + { + return $this->type; + } + + public function setType(ReviewType $type): static + { + $this->type = $type; + + return $this; + } + + /** + * @return Collection + */ + public function getAreas(): Collection + { + return $this->areas; + } + + public function addArea(ReviewArea $area): static + { + if (!$this->areas->contains($area)) { + $this->areas->add($area); + $area->setReview($this); + } + + return $this; + } + + public function removeArea(ReviewArea $area): static + { + if ($this->areas->removeElement($area)) { + // set the owning side to null (unless already changed) + if ($area->getReview() === $this) { + $area->setReview(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Project/ReviewArea.php b/src/Entity/Project/ReviewArea.php new file mode 100644 index 0000000..95a2d07 --- /dev/null +++ b/src/Entity/Project/ReviewArea.php @@ -0,0 +1,133 @@ + + */ + #[ORM\OneToMany(targetEntity: ReviewComment::class, mappedBy: 'area')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getReview(): ?Review + { + return $this->review; + } + + public function setReview(?Review $review): static + { + $this->review = $review; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function setSummary(string $summary): static + { + $this->summary = $summary; + + return $this; + } + + public function getRisk(): ?ReviewAreaRisk + { + return $this->risk; + } + + public function setRisk(?ReviewAreaRisk $risk): static + { + $this->risk = $risk; + + return $this; + } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(ReviewComment $comment): static + { + if (!$this->comments->contains($comment)) { + $this->comments->add($comment); + $comment->setArea($this); + } + + return $this; + } + + public function removeComment(ReviewComment $comment): static + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getArea() === $this) { + $comment->setArea(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Project/ReviewAreaRisk.php b/src/Entity/Project/ReviewAreaRisk.php new file mode 100644 index 0000000..8f83e16 --- /dev/null +++ b/src/Entity/Project/ReviewAreaRisk.php @@ -0,0 +1,21 @@ +id; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): static + { + $this->author = $author; + + return $this; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function setBody(string $body): static + { + $this->body = $body; + + return $this; + } + + public function getArea(): ?ReviewArea + { + return $this->area; + } + + public function setArea(?ReviewArea $area): static + { + $this->area = $area; + + return $this; + } +} diff --git a/src/Entity/Project/ReviewType.php b/src/Entity/Project/ReviewType.php new file mode 100644 index 0000000..7a411aa --- /dev/null +++ b/src/Entity/Project/ReviewType.php @@ -0,0 +1,16 @@ +hasChangedField('status')) { - return; - } - - if ($project->getStatus() === ProjectStatus::InCampaign) { - $calendar = $this->calendarService->makeCalendar($project->getDeadline()); - - $project->setCalendar($calendar); - } - } -} diff --git a/src/OpenApi/RedocExtensionsTrait.php b/src/OpenApi/RedocExtensionsTrait.php index 9bb4402..fa6d167 100644 --- a/src/OpenApi/RedocExtensionsTrait.php +++ b/src/OpenApi/RedocExtensionsTrait.php @@ -31,6 +31,9 @@ trait RedocExtensionsTrait 'name' => 'Projects', 'tags' => [ 'Project', + 'ProjectReview', + 'ProjectReviewArea', + 'ProjectReviewComment', 'ProjectReward', 'ProjectRewardClaim', 'ProjectBudgetItem', diff --git a/src/Repository/Project/ReviewAreaRepository.php b/src/Repository/Project/ReviewAreaRepository.php new file mode 100644 index 0000000..49d5f7d --- /dev/null +++ b/src/Repository/Project/ReviewAreaRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewAreaRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReviewArea::class); + } + + // /** + // * @return ReviewArea[] Returns an array of ReviewArea objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?ReviewArea + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/Project/ReviewCommentRepository.php b/src/Repository/Project/ReviewCommentRepository.php new file mode 100644 index 0000000..1ff661f --- /dev/null +++ b/src/Repository/Project/ReviewCommentRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewCommentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReviewComment::class); + } + + // /** + // * @return ReviewComment[] Returns an array of ReviewComment objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?ReviewComment + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/Project/ReviewRepository.php b/src/Repository/Project/ReviewRepository.php new file mode 100644 index 0000000..b228bcf --- /dev/null +++ b/src/Repository/Project/ReviewRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Review::class); + } + + // /** + // * @return Review[] Returns an array of Review objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Review + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Security/Voter/ReviewAreaVoter.php b/src/Security/Voter/ReviewAreaVoter.php new file mode 100644 index 0000000..5da27b5 --- /dev/null +++ b/src/Security/Voter/ReviewAreaVoter.php @@ -0,0 +1,57 @@ +getUser(); + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + /** @var ReviewArea */ + $area = $this->mapper->map($subject, ReviewArea::class); + if (!$area || !$area instanceof ReviewArea) { + return false; + } + + $project = $area->getReview()->getProject(); + + switch ($attribute) { + case self::EDIT: + return $area->getReview()->isReviewedBy($user); + case self::VIEW: + return $this->isOwnerOf($project, $user); + } + + return false; + } +} diff --git a/src/Security/Voter/UserOwnedVoter.php b/src/Security/Voter/UserOwnedVoter.php index 95f2ac9..a02fcff 100644 --- a/src/Security/Voter/UserOwnedVoter.php +++ b/src/Security/Voter/UserOwnedVoter.php @@ -32,6 +32,6 @@ class UserOwnedVoter extends Voter */ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { - return $this->isOwnerOf($subject, $token->getUser()); + return $subject !== null && $this->isOwnerOf($subject, $token->getUser()); } } diff --git a/src/Service/Project/ProjectService.php b/src/Service/Project/ProjectService.php index 375c4d7..4f5c7fb 100644 --- a/src/Service/Project/ProjectService.php +++ b/src/Service/Project/ProjectService.php @@ -4,6 +4,7 @@ namespace App\Service\Project; use App\Entity\Project\Project; use App\Entity\Project\ProjectStatus; +use App\Entity\Project\ReviewType; use App\Entity\User\User; use App\Security\Voter\UserOwnedVoterTrait; @@ -19,6 +20,11 @@ class ProjectService ProjectStatus::ToFunding->value => [ProjectStatus::InFunding], ]; + public function __construct( + private ReviewService $reviewService, + private CalendarService $calendarService, + ) {} + /** * Check if an User can move a Project to some status. * @@ -47,4 +53,34 @@ class ProjectService return false; } + + /** + * Moves a Project to a given ProjectStatus. + * + * @param ProjectStatus $to The new status to where the Project will move + * + * @return Project The resulting Project + */ + public function transition(Project $project, ProjectStatus $to): Project + { + $from = $project->getStatus(); + + if ($from === $to) { + return $project; + } + + $project->setStatus($to); + + return match ([$from, $to]) { + [ProjectStatus::InDraft, ProjectStatus::ToCampaignReview] => $project->addReview( + $this->reviewService->makeReview(ReviewType::Campaign) + ), + + [ProjectStatus::ToCampaign, ProjectStatus::InCampaign] => $project->setCalendar( + $this->calendarService->makeCalendar($project->getDeadline()) + ), + + default => $project, + }; + } } diff --git a/src/Service/Project/ReviewService.php b/src/Service/Project/ReviewService.php new file mode 100644 index 0000000..ecf8390 --- /dev/null +++ b/src/Service/Project/ReviewService.php @@ -0,0 +1,71 @@ +setType($type); + + /** @var ReviewArea[] */ + $areas = match ($type) { + ReviewType::Campaign => $this->getCampaignReviewAreas(), + ReviewType::Financial => $this->getFinancialReviewAreas(), + }; + + foreach ($areas as $area) { + $review->addArea($area); + } + + return $review; + } + + /** + * @return ReviewArea[] + */ + private function getCampaignReviewAreas(): array + { + $config = new ReviewArea(); + $config->setTitle('configuration'); + + $info = new ReviewArea(); + $info->setTitle('info'); + + $rewards = new ReviewArea(); + $rewards->setTitle('rewards'); + + $collabs = new ReviewArea(); + $collabs->setTitle('collaborations'); + + $budget = new ReviewArea(); + $budget->setTitle('budget'); + + $about = new ReviewArea(); + $about->setTitle('about'); + + return [ + $config, + $info, + $rewards, + $collabs, + $budget, + $about, + ]; + } + + /** + * @return ReviewArea[] + * + * @todo Add financial review areas + */ + private function getFinancialReviewAreas(): array + { + return []; + } +} diff --git a/src/State/Project/ProjectStateProcessor.php b/src/State/Project/ProjectStateProcessor.php index e9fcaac..8aeb4dd 100644 --- a/src/State/Project/ProjectStateProcessor.php +++ b/src/State/Project/ProjectStateProcessor.php @@ -10,8 +10,8 @@ use App\ApiResource\Project\ProjectApiResource; use App\Dto\ProjectCreationDto; use App\Dto\ProjectUpdationDto; use App\Entity\Project\Project; -use App\Entity\Project\ProjectCalendar; use App\Mapping\AutoMapper; +use App\Service\Project\CalendarService; use App\Service\Project\ProjectService; use App\State\EntityStateProcessor; use Symfony\Bundle\SecurityBundle\Security; @@ -24,6 +24,7 @@ class ProjectStateProcessor implements ProcessorInterface private AutoMapper $autoMapper, private Security $security, private ProjectService $projectService, + private CalendarService $calendarService, ) {} /** @@ -33,13 +34,11 @@ class ProjectStateProcessor implements ProcessorInterface */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - if ($data instanceof ProjectCreationDto) { - $project = $this->getProjectFromCreation($data); - } elseif ($data instanceof ProjectUpdationDto) { - $project = $this->getProjectFromUpdate($data, $context); - } else { - throw new \InvalidArgumentException('Unsupported input for Project resource'); - } + $project = match ($data::class) { + ProjectCreationDto::class => $this->getProjectFromCreation($data), + ProjectUpdationDto::class => $this->getProjectFromUpdate($data, $context), + default => throw new \InvalidArgumentException('Unsupported input for Project resource'), + }; $project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context); @@ -60,7 +59,6 @@ class ProjectStateProcessor implements ProcessorInterface $project = $this->autoMapper->map($data, Project::class); $owner = $this->security->getUser(); - if (!$owner) { throw new AuthenticationException(); } @@ -68,28 +66,25 @@ class ProjectStateProcessor implements ProcessorInterface $project->setOwner($owner); if (!isset($data->calendar->release)) { - $data->calendar->release = new \DateTimeImmutable('+28 days'); + $project->setCalendar($this->calendarService->makeCalendar( + $project->getDeadline(), + new \DateTimeImmutable('+28 days') + )); } - $calendar = new ProjectCalendar(); - $calendar->release = $data->calendar->release; - - $project->setCalendar($calendar); - return $project; } private function getProjectFromUpdate(ProjectUpdationDto $data, array $context): Project { - /** @var Project */ - $project = $this->autoMapper->map($context['previous_data'], Project::class); - $actor = $this->security->getUser(); - if (!$actor) { throw new AuthenticationException(); } + /** @var Project */ + $project = $this->autoMapper->map($context['previous_data'], Project::class); + if (isset($data->status) && $data->status !== $project->getStatus()) { if (!$this->projectService->canTransition($actor, $project, $data->status)) { throw new AccessDeniedException(\sprintf( @@ -98,6 +93,8 @@ class ProjectStateProcessor implements ProcessorInterface $data->status->value )); } + + $project = $this->projectService->transition($project, $data->status); } return $this->autoMapper->map($data, $project); diff --git a/src/Validator/UserOwned.php b/src/Validator/UserOwned.php new file mode 100644 index 0000000..71a0c75 --- /dev/null +++ b/src/Validator/UserOwned.php @@ -0,0 +1,19 @@ +iriConverter->getIriFromResource($value); + $user = $this->security->getUser(); + + if ($value && $user) { + /** @var UserApiResource */ + $resource = $this->mapper->map($user, UserApiResource::class); + $resourceIri = $this->iriConverter->getIriFromResource($resource); + + if ($value === $resourceIri) { + return; + } + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation() + ; + } +} diff --git a/tests/Entity/ProjectCalendarTest.php b/tests/Entity/ProjectCalendarTest.php index cfc2b4f..4c3eb20 100644 --- a/tests/Entity/ProjectCalendarTest.php +++ b/tests/Entity/ProjectCalendarTest.php @@ -8,6 +8,7 @@ use App\Entity\Project\ProjectDeadline; use App\Entity\Project\ProjectStatus; use App\Entity\User\User; use App\Factory\Project\ProjectFactory; +use App\Service\Project\ProjectService; use Doctrine\ORM\EntityManagerInterface; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -18,12 +19,14 @@ class ProjectCalendarTest extends ApiTestCase use Factories; private EntityManagerInterface $entityManager; + private ProjectService $projectService; private User $owner; public function setUp(): void { self::bootKernel(); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->projectService = static::getContainer()->get(ProjectService::class); $this->owner = $this->createTestUser(); $this->entityManager->persist($this->owner); @@ -44,6 +47,7 @@ class ProjectCalendarTest extends ApiTestCase { return ProjectFactory::createOne([ 'owner' => $this->owner, + 'status' => ProjectStatus::ToCampaign, 'deadline' => $deadline, ])->_real(); } @@ -56,7 +60,9 @@ class ProjectCalendarTest extends ApiTestCase $this->entityManager->persist($project); $this->entityManager->flush(); - $project->setStatus(ProjectStatus::InCampaign); + $project = $this->projectService->transition($project, ProjectStatus::InCampaign); + + $this->entityManager->persist($project); $this->entityManager->flush(); return $project; @@ -65,8 +71,11 @@ class ProjectCalendarTest extends ApiTestCase public function testReleaseDateUpdatesOnCampaignStart(): void { $project = $this->createProjectAndSetToInCampaign(); + $today = new \DateTime(); + $today->setTime(0, 0, 0, 0); $this->assertNotNull($project->getCalendar()->release); + $this->assertGreaterThan($today, $project->getCalendar()->release); } public function testMinimumDeadlineIsSetCorrectly(): void diff --git a/tests/Entity/ProjectCampaignReviewTest.php b/tests/Entity/ProjectCampaignReviewTest.php new file mode 100644 index 0000000..6c8774e --- /dev/null +++ b/tests/Entity/ProjectCampaignReviewTest.php @@ -0,0 +1,81 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->projectService = static::getContainer()->get(ProjectService::class); + + $this->owner = $this->createTestUser(); + $this->entityManager->persist($this->owner); + $this->entityManager->flush(); + } + + private function createTestUser(string $handle = 'test_user', string $email = 'testuser@example.com'): User + { + $user = new User(); + $user->setHandle($handle); + $user->setEmail($email); + $user->setPassword('projectapitestpassword'); + + return $user; + } + + private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project + { + return ProjectFactory::createOne([ + 'owner' => $this->owner, + 'deadline' => $deadline, + 'status' => ProjectStatus::InDraft, + ])->_real(); + } + + private function createProjectAndSetToInCampaignReview(): Project + { + $project = $this->createTestProject(); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + $project = $this->projectService->transition($project, ProjectStatus::ToCampaignReview); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return $project; + } + + public function testCreatesReviewOnStatusChange(): void + { + $project = $this->createProjectAndSetToInCampaignReview(); + $reviews = $this->entityManager->getRepository(Review::class)->findBy(['project' => $project->getId()]); + + $this->assertNotEmpty($reviews); + $this->assertEquals(1, \count($reviews)); + $this->assertEquals(ReviewType::Campaign->value, $reviews[0]->getType()->value); + } +}