From d1296e8705beca147fa47b84a34785d81ddec53a Mon Sep 17 00:00:00 2001 From: dcorroyer Date: Tue, 22 Oct 2024 16:59:44 +0200 Subject: [PATCH] feat: backend savings --- app/migrations/Version20241026154742.php | 44 +++ app/phpunit.xml.dist | 1 + .../Account/CreateAccountController.php | 50 +++ .../Account/DeleteAccountController.php | 43 +++ .../Account/GetAccountController.php | 41 +++ .../Account/ListAccountController.php | 41 +++ .../Account/UpdateAccountController.php | 50 +++ .../Budget/DeleteBudgetController.php | 4 +- .../Controller/Budget/GetBudgetController.php | 8 +- .../CreateTransactionController.php | 51 +++ .../DeleteTransactionController.php | 46 +++ .../Transaction/GetTransactionController.php | 44 +++ .../Transaction/ListTransactionController.php | 48 +++ .../UpdateTransactionController.php | 51 +++ .../Dto/Account/Payload/AccountPayload.php | 15 + .../Payload/TransactionPayload.php | 26 ++ app/src/Entity/Account.php | 164 +++++++++ app/src/Entity/Transaction.php | 164 +++++++++ app/src/Entity/User.php | 40 ++- app/src/Enum/AccountTypesEnum.php | 10 + app/src/Enum/TransactionTypesEnum.php | 11 + app/src/Repository/AccountRepository.php | 20 ++ app/src/Repository/TransactionRepository.php | 20 ++ app/src/Security/Voter/AccountVoter.php | 39 ++ app/src/Security/Voter/TransactionVoter.php | 65 ++++ app/src/Serializable/SerializationGroups.php | 16 + app/src/Service/AccountService.php | 87 +++++ app/src/Service/BudgetService.php | 4 +- app/src/Service/TransactionService.php | 109 ++++++ app/tests/Common/Factory/AccountFactory.php | 46 +++ .../Common/Factory/TransactionFactory.php | 55 +++ .../Account/CreateAccountControllerTest.php | 46 +++ .../Account/DeleteAccountControllerTest.php | 46 +++ .../Account/GetAccountControllerTest.php | 46 +++ .../Account/ListAccountControllerTest.php | 46 +++ .../Account/UpdateAccountControllerTest.php | 55 +++ .../Authentication/RegisterControllerTest.php | 74 ---- .../CreateTransactionControllerTest.php | 60 ++++ .../DeleteTransactionControllerTest.php | 51 +++ .../GetTransactionControllerTest.php | 54 +++ .../ListTransactionControllerTest.php | 53 +++ .../UpdateTransactionControllerTest.php | 64 ++++ .../Repository/AccountRepositoryTest.php | 60 ++++ .../Repository/BudgetRepositoryTest.php | 1 + .../Repository/IncomeRepositoryTest.php | 1 + .../Repository/UserRepositoryTest.php | 1 + app/tests/Unit/Service/AccountServiceTest.php | 285 +++++++++++++++ app/tests/Unit/Service/BudgetServiceTest.php | 20 +- .../Unit/Service/TransactionServiceTest.php | 335 ++++++++++++++++++ 49 files changed, 2618 insertions(+), 93 deletions(-) create mode 100644 app/migrations/Version20241026154742.php create mode 100644 app/src/Controller/Account/CreateAccountController.php create mode 100644 app/src/Controller/Account/DeleteAccountController.php create mode 100644 app/src/Controller/Account/GetAccountController.php create mode 100644 app/src/Controller/Account/ListAccountController.php create mode 100644 app/src/Controller/Account/UpdateAccountController.php create mode 100644 app/src/Controller/Transaction/CreateTransactionController.php create mode 100644 app/src/Controller/Transaction/DeleteTransactionController.php create mode 100644 app/src/Controller/Transaction/GetTransactionController.php create mode 100644 app/src/Controller/Transaction/ListTransactionController.php create mode 100644 app/src/Controller/Transaction/UpdateTransactionController.php create mode 100644 app/src/Dto/Account/Payload/AccountPayload.php create mode 100644 app/src/Dto/Transaction/Payload/TransactionPayload.php create mode 100644 app/src/Entity/Account.php create mode 100644 app/src/Entity/Transaction.php create mode 100644 app/src/Enum/AccountTypesEnum.php create mode 100644 app/src/Enum/TransactionTypesEnum.php create mode 100644 app/src/Repository/AccountRepository.php create mode 100644 app/src/Repository/TransactionRepository.php create mode 100644 app/src/Security/Voter/AccountVoter.php create mode 100644 app/src/Security/Voter/TransactionVoter.php create mode 100644 app/src/Service/AccountService.php create mode 100644 app/src/Service/TransactionService.php create mode 100644 app/tests/Common/Factory/AccountFactory.php create mode 100644 app/tests/Common/Factory/TransactionFactory.php create mode 100644 app/tests/Functional/Account/CreateAccountControllerTest.php create mode 100644 app/tests/Functional/Account/DeleteAccountControllerTest.php create mode 100644 app/tests/Functional/Account/GetAccountControllerTest.php create mode 100644 app/tests/Functional/Account/ListAccountControllerTest.php create mode 100644 app/tests/Functional/Account/UpdateAccountControllerTest.php create mode 100644 app/tests/Functional/Transaction/CreateTransactionControllerTest.php create mode 100644 app/tests/Functional/Transaction/DeleteTransactionControllerTest.php create mode 100644 app/tests/Functional/Transaction/GetTransactionControllerTest.php create mode 100644 app/tests/Functional/Transaction/ListTransactionControllerTest.php create mode 100644 app/tests/Functional/Transaction/UpdateTransactionControllerTest.php create mode 100644 app/tests/Integration/Repository/AccountRepositoryTest.php create mode 100644 app/tests/Unit/Service/AccountServiceTest.php create mode 100644 app/tests/Unit/Service/TransactionServiceTest.php diff --git a/app/migrations/Version20241026154742.php b/app/migrations/Version20241026154742.php new file mode 100644 index 0000000..e13fe6d --- /dev/null +++ b/app/migrations/Version20241026154742.php @@ -0,0 +1,44 @@ +addSql('CREATE SEQUENCE "account_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE "transaction_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE "account" (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, amount DOUBLE PRECISION NOT NULL, type VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_7D3656A4A76ED395 ON "account" (user_id)'); + $this->addSql('CREATE TABLE "transaction" (id INT NOT NULL, account_id INT NOT NULL, description VARCHAR(255) NOT NULL, amount DOUBLE PRECISION NOT NULL, type VARCHAR(255) NOT NULL, date DATE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_723705D19B6B5FBA ON "transaction" (account_id)'); + $this->addSql('ALTER TABLE "account" ADD CONSTRAINT FK_7D3656A4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE "transaction" ADD CONSTRAINT FK_723705D19B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP SEQUENCE "account_id_seq" CASCADE'); + $this->addSql('DROP SEQUENCE "transaction_id_seq" CASCADE'); + $this->addSql('ALTER TABLE "account" DROP CONSTRAINT FK_7D3656A4A76ED395'); + $this->addSql('ALTER TABLE "transaction" DROP CONSTRAINT FK_723705D19B6B5FBA'); + $this->addSql('DROP TABLE "account"'); + $this->addSql('DROP TABLE "transaction"'); + } +} diff --git a/app/phpunit.xml.dist b/app/phpunit.xml.dist index f8c384b..d9a247b 100644 --- a/app/phpunit.xml.dist +++ b/app/phpunit.xml.dist @@ -44,6 +44,7 @@ src/Serializable src/DataFixtures src/Entity + src/Enum src/Kernel.php diff --git a/app/src/Controller/Account/CreateAccountController.php b/app/src/Controller/Account/CreateAccountController.php new file mode 100644 index 0000000..1a9222c --- /dev/null +++ b/app/src/Controller/Account/CreateAccountController.php @@ -0,0 +1,50 @@ +successResponse( + data: $accountService->create($accountPayload), + groups: [SerializationGroups::ACCOUNT_CREATE], + status: Response::HTTP_CREATED, + ); + } +} diff --git a/app/src/Controller/Account/DeleteAccountController.php b/app/src/Controller/Account/DeleteAccountController.php new file mode 100644 index 0000000..03a3e7a --- /dev/null +++ b/app/src/Controller/Account/DeleteAccountController.php @@ -0,0 +1,43 @@ +delete($account); + + return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); + } +} diff --git a/app/src/Controller/Account/GetAccountController.php b/app/src/Controller/Account/GetAccountController.php new file mode 100644 index 0000000..49eb52e --- /dev/null +++ b/app/src/Controller/Account/GetAccountController.php @@ -0,0 +1,41 @@ +successResponse(data: $accountService->get($id), groups: [SerializationGroups::ACCOUNT_GET]); + } +} diff --git a/app/src/Controller/Account/ListAccountController.php b/app/src/Controller/Account/ListAccountController.php new file mode 100644 index 0000000..eeb496f --- /dev/null +++ b/app/src/Controller/Account/ListAccountController.php @@ -0,0 +1,41 @@ +successResponse($accountService->list(), [SerializationGroups::ACCOUNT_LIST]); + } +} diff --git a/app/src/Controller/Account/UpdateAccountController.php b/app/src/Controller/Account/UpdateAccountController.php new file mode 100644 index 0000000..2e7cd3e --- /dev/null +++ b/app/src/Controller/Account/UpdateAccountController.php @@ -0,0 +1,50 @@ +successResponse( + data: $accountService->update($accountPayload, $account), + groups: [SerializationGroups::ACCOUNT_UPDATE] + ); + } +} diff --git a/app/src/Controller/Budget/DeleteBudgetController.php b/app/src/Controller/Budget/DeleteBudgetController.php index 7ba6ef8..4bea3cc 100644 --- a/app/src/Controller/Budget/DeleteBudgetController.php +++ b/app/src/Controller/Budget/DeleteBudgetController.php @@ -36,6 +36,8 @@ class DeleteBudgetController extends BaseRestController #[Route('/{id}', name: 'api_budgets_delete', methods: Request::METHOD_DELETE)] public function __invoke(BudgetService $budgetService, Budget $budget): JsonResponse { - return $this->successResponse(data: $budgetService->delete($budget), status: Response::HTTP_NO_CONTENT); + $budgetService->delete($budget); + + return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); } } diff --git a/app/src/Controller/Budget/GetBudgetController.php b/app/src/Controller/Budget/GetBudgetController.php index 9c4f19f..635bb35 100644 --- a/app/src/Controller/Budget/GetBudgetController.php +++ b/app/src/Controller/Budget/GetBudgetController.php @@ -25,9 +25,11 @@ class GetBudgetController extends BaseRestController operationId: 'get_budget', summary: 'get budget', responses: [ - new SuccessResponse(responseClassFqcn: Budget::class, groups: [ - SerializationGroups::BUDGET_GET, - ], description: 'Budget get'), + new SuccessResponse( + responseClassFqcn: Budget::class, + groups: [SerializationGroups::BUDGET_GET], + description: 'Budget get', + ), new NotFoundResponse(description: 'Budget not found'), ], )] diff --git a/app/src/Controller/Transaction/CreateTransactionController.php b/app/src/Controller/Transaction/CreateTransactionController.php new file mode 100644 index 0000000..32bcc41 --- /dev/null +++ b/app/src/Controller/Transaction/CreateTransactionController.php @@ -0,0 +1,51 @@ +successResponse( + data: $transactionService->create($accountId, $transactionPayload), + groups: [SerializationGroups::TRANSACTION_CREATE], + status: Response::HTTP_CREATED, + ); + } +} diff --git a/app/src/Controller/Transaction/DeleteTransactionController.php b/app/src/Controller/Transaction/DeleteTransactionController.php new file mode 100644 index 0000000..32f7c95 --- /dev/null +++ b/app/src/Controller/Transaction/DeleteTransactionController.php @@ -0,0 +1,46 @@ +delete($transaction); + + return $this->successResponse(data: [], status: Response::HTTP_NO_CONTENT); + } +} diff --git a/app/src/Controller/Transaction/GetTransactionController.php b/app/src/Controller/Transaction/GetTransactionController.php new file mode 100644 index 0000000..bbb5aec --- /dev/null +++ b/app/src/Controller/Transaction/GetTransactionController.php @@ -0,0 +1,44 @@ +successResponse( + data: $transactionService->get($id), + groups: [SerializationGroups::TRANSACTION_GET] + ); + } +} diff --git a/app/src/Controller/Transaction/ListTransactionController.php b/app/src/Controller/Transaction/ListTransactionController.php new file mode 100644 index 0000000..c904f88 --- /dev/null +++ b/app/src/Controller/Transaction/ListTransactionController.php @@ -0,0 +1,48 @@ +paginateResponse( + $transactionService->paginate($accountId, $paginationQueryParams), + [SerializationGroups::TRANSACTION_LIST] + ); + } +} diff --git a/app/src/Controller/Transaction/UpdateTransactionController.php b/app/src/Controller/Transaction/UpdateTransactionController.php new file mode 100644 index 0000000..4fc81e7 --- /dev/null +++ b/app/src/Controller/Transaction/UpdateTransactionController.php @@ -0,0 +1,51 @@ +successResponse( + data: $transactionService->update($transactionPayload, $transaction), + groups: [SerializationGroups::TRANSACTION_UPDATE] + ); + } +} diff --git a/app/src/Dto/Account/Payload/AccountPayload.php b/app/src/Dto/Account/Payload/AccountPayload.php new file mode 100644 index 0000000..95eac6f --- /dev/null +++ b/app/src/Dto/Account/Payload/AccountPayload.php @@ -0,0 +1,15 @@ + + */ + #[Serializer\Groups([SerializationGroups::ACCOUNT_GET])] + #[ORM\OneToMany(targetEntity: Transaction::class, mappedBy: 'account', orphanRemoval: true)] + private Collection $transactions; + + #[ORM\ManyToOne(inversedBy: 'budgets')] + #[ORM\JoinColumn(nullable: false)] + private ?User $user = null; + + public function __construct() + { + $this->transactions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setAmount(float $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function getType(): AccountTypesEnum + { + return $this->type; + } + + public function setType(AccountTypesEnum $type): static + { + $this->type = $type; + + return $this; + } + + /** + * @return Collection + */ + public function getTransactions(): Collection + { + return $this->transactions; + } + + public function addTransaction(Transaction $transaction): static + { + if (! $this->transactions->contains($transaction)) { + $this->transactions->add($transaction); + $transaction->setAccount($this); + } + + return $this; + } + + public function removeTransaction(Transaction $transaction): static + { + if ($this->transactions->removeElement($transaction) && $transaction->getAccount() === $this) { + $transaction->setAccount(null); + } + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } +} diff --git a/app/src/Entity/Transaction.php b/app/src/Entity/Transaction.php new file mode 100644 index 0000000..fb2ac9b --- /dev/null +++ b/app/src/Entity/Transaction.php @@ -0,0 +1,164 @@ + 'Y-m-d', + ], + denormalizationContext: [ + DateTimeNormalizer::FORMAT_KEY => 'Y-m-d', + ], + )] + #[Assert\NotBlank] + #[Assert\Date] + #[ORM\Column(type: Types::DATE_MUTABLE)] + #[Serializer\Groups([ + SerializationGroups::TRANSACTION_GET, + SerializationGroups::TRANSACTION_LIST, + SerializationGroups::TRANSACTION_CREATE, + SerializationGroups::TRANSACTION_UPDATE, + ])] + private \DateTimeInterface $date; + + #[ORM\ManyToOne(inversedBy: 'transactions')] + #[ORM\JoinColumn(nullable: false)] + #[Serializer\Groups([SerializationGroups::TRANSACTION_GET])] + private ?Account $account = null; + + public function __construct() + { + $this->date = Carbon::now(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setAmount(float $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function getType(): TransactionTypesEnum + { + return $this->type; + } + + public function setType(TransactionTypesEnum $type): static + { + $this->type = $type; + + return $this; + } + + public function getDate(): \DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): static + { + $this->date = $date; + + return $this; + } + + public function getAccount(): ?Account + { + return $this->account; + } + + public function setAccount(?Account $account): static + { + $this->account = $account; + + return $this; + } +} diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php index 0e240b8..b8a5941 100644 --- a/app/src/Entity/User.php +++ b/app/src/Entity/User.php @@ -57,13 +57,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * @var Collection */ - #[Serializer\Groups([SerializationGroups::USER_GET])] #[ORM\OneToMany(targetEntity: Budget::class, mappedBy: 'user', orphanRemoval: true)] private Collection $budgets; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Account::class, mappedBy: 'user', orphanRemoval: true)] + private Collection $accounts; + public function __construct() { $this->budgets = new ArrayCollection(); + $this->accounts = new ArrayCollection(); } public function getId(): int @@ -171,7 +177,7 @@ public function getBudgets(): Collection return $this->budgets; } - public function addBudget(Budget $budget): self + public function addBudget(Budget $budget): static { if (! $this->budgets->contains($budget)) { $this->budgets->add($budget); @@ -181,13 +187,39 @@ public function addBudget(Budget $budget): self return $this; } - public function removeBudget(Budget $budget): self + public function removeBudget(Budget $budget): static { - // set the owning side to null (unless already changed) if ($this->budgets->removeElement($budget) && $budget->getUser() === $this) { $budget->setUser(null); } return $this; } + + /** + * @return Collection + */ + public function getAccounts(): Collection + { + return $this->accounts; + } + + public function addAccount(Account $account): static + { + if (! $this->accounts->contains($account)) { + $this->accounts->add($account); + $account->setUser($this); + } + + return $this; + } + + public function removeAccount(Account $account): static + { + if ($this->accounts->removeElement($account) && $account->getUser() === $this) { + $account->setUser(null); + } + + return $this; + } } diff --git a/app/src/Enum/AccountTypesEnum.php b/app/src/Enum/AccountTypesEnum.php new file mode 100644 index 0000000..ab841a8 --- /dev/null +++ b/app/src/Enum/AccountTypesEnum.php @@ -0,0 +1,10 @@ + + */ +class AccountRepository extends AbstractEntityRepository +{ + #[\Override] + public function getEntityClass(): string + { + return Account::class; + } +} diff --git a/app/src/Repository/TransactionRepository.php b/app/src/Repository/TransactionRepository.php new file mode 100644 index 0000000..986f747 --- /dev/null +++ b/app/src/Repository/TransactionRepository.php @@ -0,0 +1,20 @@ + + */ +class TransactionRepository extends AbstractEntityRepository +{ + #[\Override] + public function getEntityClass(): string + { + return Transaction::class; + } +} diff --git a/app/src/Security/Voter/AccountVoter.php b/app/src/Security/Voter/AccountVoter.php new file mode 100644 index 0000000..6212141 --- /dev/null +++ b/app/src/Security/Voter/AccountVoter.php @@ -0,0 +1,39 @@ + + */ +class AccountVoter extends Voter +{ + public const string VIEW = 'view'; + public const string EDIT = 'edit'; + public const string DELETE = 'delete'; + + protected function supports(string $attribute, mixed $subject): bool + { + return \in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true) + && $subject instanceof Account; + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + $user = $token->getUser(); + if (! $user instanceof User) { + return false; + } + + /** @var Account $account */ + $account = $subject; + + return $account->getUser() === $user; + } +} diff --git a/app/src/Security/Voter/TransactionVoter.php b/app/src/Security/Voter/TransactionVoter.php new file mode 100644 index 0000000..369b387 --- /dev/null +++ b/app/src/Security/Voter/TransactionVoter.php @@ -0,0 +1,65 @@ + + */ +class TransactionVoter extends Voter +{ + public const string VIEW = 'view'; + public const string EDIT = 'edit'; + public const string DELETE = 'delete'; + public const string CREATE = 'create'; + + protected function supports(string $attribute, mixed $subject): bool + { + return \in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::CREATE], true) + && ($subject instanceof Transaction || $subject instanceof Account); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + $user = $token->getUser(); + if (! $user instanceof User) { + return false; + } + + if ($attribute === self::CREATE && $subject instanceof Account) { + return $this->canCreate($subject, $user); + } + + if (! $subject instanceof Transaction) { + return false; + } + + return match ($attribute) { + self::VIEW, self::EDIT, self::DELETE => $this->canAccess($subject, $user), + default => false, + }; + } + + private function canAccess(Transaction $transaction, User $user): bool + { + $account = $transaction->getAccount(); + if ($account === null) { + return false; + } + + return $account->getUser() === $user + && $user->getAccounts()->contains($account); + } + + private function canCreate(Account $account, User $user): bool + { + return $account->getUser() === $user; + } +} diff --git a/app/src/Serializable/SerializationGroups.php b/app/src/Serializable/SerializationGroups.php index 37d7684..da74bcd 100644 --- a/app/src/Serializable/SerializationGroups.php +++ b/app/src/Serializable/SerializationGroups.php @@ -14,6 +14,22 @@ final class SerializationGroups public const string BUDGET_LIST = 'BUDGET_LIST'; + public const string ACCOUNT_CREATE = 'ACCOUNT_CREATE'; + + public const string ACCOUNT_UPDATE = 'ACCOUNT_UPDATE'; + + public const string ACCOUNT_GET = 'ACCOUNT_GET'; + + public const string ACCOUNT_LIST = 'ACCOUNT_LIST'; + + public const string TRANSACTION_CREATE = 'TRANSACTION_CREATE'; + + public const string TRANSACTION_UPDATE = 'TRANSACTION_UPDATE'; + + public const string TRANSACTION_GET = 'TRANSACTION_GET'; + + public const string TRANSACTION_LIST = 'TRANSACTION_LIST'; + public const string USER_CREATE = 'USER_CREATE'; public const string USER_GET = 'USER_GET'; diff --git a/app/src/Service/AccountService.php b/app/src/Service/AccountService.php new file mode 100644 index 0000000..68028c9 --- /dev/null +++ b/app/src/Service/AccountService.php @@ -0,0 +1,87 @@ +accountRepository->find($id); + + if ($account === null) { + throw new NotFoundHttpException('Account not found'); + } + + if (! $this->authorizationChecker->isGranted('view', $account)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + return $account; + } + + public function create(AccountPayload $accountPayload): Account + { + /** @var User $user */ + $user = $this->security->getUser(); + + $account = new Account(); + + $account->setName($accountPayload->name) + ->setUser($user) + ; + + $this->accountRepository->save($account, true); + + return $account; + } + + public function update(AccountPayload $accountPayload, Account $account): Account + { + if (! $this->authorizationChecker->isGranted('edit', $account)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $account->setName($accountPayload->name); + + $this->accountRepository->save($account, true); + + return $account; + } + + public function delete(Account $account): void + { + if (! $this->authorizationChecker->isGranted('delete', $account)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $this->accountRepository->delete($account, true); + } + + /** + * @return list + */ + public function list(): iterable + { + return $this->accountRepository->findBy([ + 'user' => $this->security->getUser(), + ]); + } +} diff --git a/app/src/Service/BudgetService.php b/app/src/Service/BudgetService.php index 3acb561..d8243b6 100644 --- a/app/src/Service/BudgetService.php +++ b/app/src/Service/BudgetService.php @@ -83,13 +83,11 @@ private function createOrUpdateBudget(BudgetPayload $budgetPayload, Budget $budg return $budget; } - public function delete(Budget $budget): Budget + public function delete(Budget $budget): void { $this->checkAccess($budget); $this->budgetRepository->delete($budget, true); - - return $budget; } public function duplicate(?int $id = null): Budget diff --git a/app/src/Service/TransactionService.php b/app/src/Service/TransactionService.php new file mode 100644 index 0000000..a13d000 --- /dev/null +++ b/app/src/Service/TransactionService.php @@ -0,0 +1,109 @@ +transactionRepository->find($id); + + if ($transaction === null) { + throw new NotFoundHttpException('Transaction not found'); + } + + if (! $this->authorizationChecker->isGranted('view', $transaction)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + return $transaction; + } + + public function create(int $accountId, TransactionPayload $transactionPayload): Transaction + { + $account = $this->accountService->get($accountId); + + if (! $this->authorizationChecker->isGranted('create', $account)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $transaction = new Transaction(); + + $transaction->setDescription($transactionPayload->description) + ->setAmount($transactionPayload->amount) + ->setType($transactionPayload->type) + ->setDate($transactionPayload->date) + ->setAccount($account) + ; + + $this->transactionRepository->save($transaction, true); + + return $transaction; + } + + public function update(TransactionPayload $transactionPayload, Transaction $transaction): Transaction + { + if (! $this->authorizationChecker->isGranted('edit', $transaction)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $transaction->setDescription($transactionPayload->description) + ->setAmount($transactionPayload->amount) + ->setType($transactionPayload->type) + ->setDate($transactionPayload->date) + ; + + $this->transactionRepository->save($transaction, true); + + return $transaction; + } + + public function delete(Transaction $transaction): void + { + if (! $this->authorizationChecker->isGranted('delete', $transaction)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $this->transactionRepository->delete($transaction, true); + } + + /** + * @return SlidingPagination + */ + public function paginate(int $accountId, ?PaginationQueryParams $paginationQueryParams = null): SlidingPagination + { + $account = $this->accountService->get($accountId); + + if (! $this->authorizationChecker->isGranted('view', $account)) { + throw new AccessDeniedHttpException('Access Denied.'); + } + + $criteria = Criteria::create(); + $criteria->andWhere(Criteria::expr()->eq('account', $account)) + ->orderBy([ + 'date' => 'DESC', + ]) + ; + + return $this->transactionRepository->paginate($paginationQueryParams, null, $criteria); + } +} diff --git a/app/tests/Common/Factory/AccountFactory.php b/app/tests/Common/Factory/AccountFactory.php new file mode 100644 index 0000000..8425a92 --- /dev/null +++ b/app/tests/Common/Factory/AccountFactory.php @@ -0,0 +1,46 @@ + + */ +final class AccountFactory extends PersistentProxyObjectFactory +{ + #[\Override] + public static function class(): string + { + return Account::class; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + */ + #[\Override] + protected function defaults(): array|callable + { + return [ + 'id' => self::faker()->randomNumber(), + 'amount' => self::faker()->randomFloat(), + 'name' => self::faker()->text(255), + 'type' => self::faker()->randomElement(AccountTypesEnum::cases()), + 'user' => UserFactory::new(), + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + #[\Override] + protected function initialize(): static + { + return $this; + // ->afterInstantiate(function(Account $account): void {}) + } +} diff --git a/app/tests/Common/Factory/TransactionFactory.php b/app/tests/Common/Factory/TransactionFactory.php new file mode 100644 index 0000000..9bd259c --- /dev/null +++ b/app/tests/Common/Factory/TransactionFactory.php @@ -0,0 +1,55 @@ + + */ +final class TransactionFactory extends PersistentProxyObjectFactory +{ + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + * + * @todo inject services if required + */ + public function __construct() + { + } + + public static function class(): string + { + return Transaction::class; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + * + * @todo add your default values here + */ + protected function defaults(): array|callable + { + return [ + 'id' => self::faker()->randomNumber(), + 'account' => AccountFactory::new(), + 'amount' => self::faker()->randomFloat(), + 'date' => self::faker()->dateTime(), + 'description' => self::faker()->text(255), + 'type' => self::faker()->randomElement(TransactionTypesEnum::cases()), + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): static + { + return $this; + // ->afterInstantiate(function(Transaction $transaction): void {}) + } +} diff --git a/app/tests/Functional/Account/CreateAccountControllerTest.php b/app/tests/Functional/Account/CreateAccountControllerTest.php new file mode 100644 index 0000000..de93747 --- /dev/null +++ b/app/tests/Functional/Account/CreateAccountControllerTest.php @@ -0,0 +1,46 @@ +_real(); + $this->client->loginUser($user); + + $accountPayload = [ + 'name' => 'Livret', + ]; + + // ACT + $response = $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $accountPayload); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertResponseFormatSame('json'); + self::assertSame('Livret', $responseData['name']); + } +} diff --git a/app/tests/Functional/Account/DeleteAccountControllerTest.php b/app/tests/Functional/Account/DeleteAccountControllerTest.php new file mode 100644 index 0000000..346fdcf --- /dev/null +++ b/app/tests/Functional/Account/DeleteAccountControllerTest.php @@ -0,0 +1,46 @@ +_real(); + $this->client->loginUser($user); + + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + + // ACT + $response = $this->clientRequest(Request::METHOD_DELETE, self::API_ENDPOINT . '/' . $account->getId()); + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertSame(Response::HTTP_NO_CONTENT, $response); + } +} diff --git a/app/tests/Functional/Account/GetAccountControllerTest.php b/app/tests/Functional/Account/GetAccountControllerTest.php new file mode 100644 index 0000000..8b9cca0 --- /dev/null +++ b/app/tests/Functional/Account/GetAccountControllerTest.php @@ -0,0 +1,46 @@ +_real(); + $this->client->loginUser($user); + + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + + // ACT + $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT . '/' . $account->getId()); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertSame($account->getId(), $responseData['id']); + } +} diff --git a/app/tests/Functional/Account/ListAccountControllerTest.php b/app/tests/Functional/Account/ListAccountControllerTest.php new file mode 100644 index 0000000..a3ac126 --- /dev/null +++ b/app/tests/Functional/Account/ListAccountControllerTest.php @@ -0,0 +1,46 @@ +_real(); + $this->client->loginUser($user); + + $accounts = AccountFactory::createMany(3, [ + 'user' => $user, + ]); + + // ACT + $response = $this->clientRequest(Request::METHOD_GET, self::API_ENDPOINT); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertCount(\count($accounts), $responseData); + } +} diff --git a/app/tests/Functional/Account/UpdateAccountControllerTest.php b/app/tests/Functional/Account/UpdateAccountControllerTest.php new file mode 100644 index 0000000..793b402 --- /dev/null +++ b/app/tests/Functional/Account/UpdateAccountControllerTest.php @@ -0,0 +1,55 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ])->_real(); + + $this->client->loginUser($user); + + $accountPayload = [ + 'name' => 'Livret', + ]; + + // ACT + $response = $this->clientRequest( + Request::METHOD_PATCH, + self::API_ENDPOINT . '/' . $account->getId(), + $accountPayload + ); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseIsSuccessful(); + self::assertResponseFormatSame('json'); + self::assertSame('Livret', $responseData['name']); + } +} diff --git a/app/tests/Functional/Authentication/RegisterControllerTest.php b/app/tests/Functional/Authentication/RegisterControllerTest.php index ddeeb81..cc55390 100644 --- a/app/tests/Functional/Authentication/RegisterControllerTest.php +++ b/app/tests/Functional/Authentication/RegisterControllerTest.php @@ -21,80 +21,6 @@ class RegisterControllerTest extends TestBase { private const string API_ENDPOINT = '/api/register'; - #[Test] - #[TestDox('When call /api/register without Email, it should return error')] - public function RegisterControllerTestWithoutEmail(): void - { - // ARRANGE - $payload = [ - 'firstName' => 'Cordia Hirthe V', - 'lastName' => 'Rebecca Marks', - 'password' => 'Isidro Kutch I', - ]; - - // ACT - $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $payload); - - // ASSERT - self::assertResponseStatusCodeSame(422); - $response = json_decode($this->client->getResponse()->getContent(), true); - self::assertSame('This value should not be blank.', $response['violations'][0]['title']); - } - - #[Test] - #[TestDox('When call /api/register without FirstName, it should return error')] - public function RegisterControllerTestWithoutFirstName(): void - { - // ARRANGE - $payload = [ - 'email' => 'Miss Laurianne Hermann', - 'lastName' => 'Evan Fadel', - 'password' => 'Prof. Shyann Pagac', - ]; - - // ACT - $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $payload); - - // ASSERT - self::assertResponseStatusCodeSame(422); - } - - #[Test] - #[TestDox('When call /api/register without LastName, it should return error')] - public function RegisterControllerTestWithoutLastName(): void - { - // ARRANGE - $payload = [ - 'email' => 'Emmitt Roob', - 'firstName' => 'Miss Marquise Dickinson II', - 'password' => 'Arianna Muller', - ]; - - // ACT - $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $payload); - - // ASSERT - self::assertResponseStatusCodeSame(422); - } - - #[Test] - #[TestDox('When call /api/register without Password, it should return error')] - public function RegisterControllerTestWithoutPassword(): void - { - // ARRANGE - $payload = [ - 'email' => 'Josianne Brekke', - 'firstName' => 'Queen Spencer', - 'lastName' => 'Nicola Sporer', - ]; - - // ACT - $this->clientRequest(Request::METHOD_POST, self::API_ENDPOINT, $payload); - - // ASSERT - self::assertResponseStatusCodeSame(422); - } - #[TestDox('When you call POST /api/register, it should create and return the user')] #[Test] public function createRegisterController_WhenDataOk_ReturnsUser(): void diff --git a/app/tests/Functional/Transaction/CreateTransactionControllerTest.php b/app/tests/Functional/Transaction/CreateTransactionControllerTest.php new file mode 100644 index 0000000..fe55b4e --- /dev/null +++ b/app/tests/Functional/Transaction/CreateTransactionControllerTest.php @@ -0,0 +1,60 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $this->client->loginUser($user); + + $transactionPayload = [ + 'description' => 'Test transaction', + 'amount' => 100, + 'type' => 'Debit', + 'date' => (new \DateTime())->format('Y-m-d H:i:s'), + ]; + + // ACT + $response = $this->clientRequest( + Request::METHOD_POST, + self::API_BASE_ENDPOINT . $account->getId() . '/transactions', + $transactionPayload + ); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseStatusCodeSame(Response::HTTP_CREATED); + self::assertArrayHasKey('id', $responseData); + self::assertSame($transactionPayload['description'], $responseData['description']); + self::assertSame($transactionPayload['amount'], $responseData['amount']); + self::assertSame($transactionPayload['type'], $responseData['type']); + } +} diff --git a/app/tests/Functional/Transaction/DeleteTransactionControllerTest.php b/app/tests/Functional/Transaction/DeleteTransactionControllerTest.php new file mode 100644 index 0000000..902a0ce --- /dev/null +++ b/app/tests/Functional/Transaction/DeleteTransactionControllerTest.php @@ -0,0 +1,51 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ])->_real(); + $transaction = TransactionFactory::createOne([ + 'account' => $account, + ])->_real(); + $this->client->loginUser($user); + + // ACT + $this->clientRequest( + Request::METHOD_DELETE, + self::API_BASE_ENDPOINT . $account->getId() . '/transactions/' . $transaction->getId() + ); + + // ASSERT + self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); + } +} diff --git a/app/tests/Functional/Transaction/GetTransactionControllerTest.php b/app/tests/Functional/Transaction/GetTransactionControllerTest.php new file mode 100644 index 0000000..ad334da --- /dev/null +++ b/app/tests/Functional/Transaction/GetTransactionControllerTest.php @@ -0,0 +1,54 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ])->_real(); + $transaction = TransactionFactory::createOne([ + 'account' => $account, + ])->_real(); + $this->client->loginUser($user); + + // ACT + $response = $this->clientRequest( + Request::METHOD_GET, + self::API_BASE_ENDPOINT . $account->getId() . '/transactions/' . $transaction->getId() + ); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSame($transaction->getId(), $responseData['id']); + self::assertSame($transaction->getDescription(), $responseData['description']); + } +} diff --git a/app/tests/Functional/Transaction/ListTransactionControllerTest.php b/app/tests/Functional/Transaction/ListTransactionControllerTest.php new file mode 100644 index 0000000..e86202c --- /dev/null +++ b/app/tests/Functional/Transaction/ListTransactionControllerTest.php @@ -0,0 +1,53 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ])->_real(); + TransactionFactory::createMany(3, [ + 'account' => $account, + ]); + $this->client->loginUser($user); + + // ACT + $response = $this->clientRequest( + Request::METHOD_GET, + self::API_BASE_ENDPOINT . $account->getId() . '/transactions' + ); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertCount(3, $responseData); + } +} diff --git a/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php b/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php new file mode 100644 index 0000000..7318cac --- /dev/null +++ b/app/tests/Functional/Transaction/UpdateTransactionControllerTest.php @@ -0,0 +1,64 @@ +_real(); + $account = AccountFactory::createOne([ + 'user' => $user, + ])->_real(); + $transaction = TransactionFactory::createOne([ + 'account' => $account, + ])->_real(); + $this->client->loginUser($user); + + $updatePayload = [ + 'description' => 'Updated transaction', + 'amount' => 200, + 'type' => TransactionTypesEnum::DEBIT, + 'date' => (new \DateTime())->format('Y-m-d H:i:s'), + ]; + + // ACT + $response = $this->clientRequest( + Request::METHOD_PUT, + self::API_BASE_ENDPOINT . $account->getId() . '/transactions/' . $transaction->getId(), + $updatePayload + ); + $responseData = $response['data'] ?? []; + + // ASSERT + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertSame($transaction->getId(), $responseData['id']); + self::assertSame($updatePayload['description'], $responseData['description']); + self::assertSame($updatePayload['amount'], $responseData['amount']); + } +} diff --git a/app/tests/Integration/Repository/AccountRepositoryTest.php b/app/tests/Integration/Repository/AccountRepositoryTest.php new file mode 100644 index 0000000..126d761 --- /dev/null +++ b/app/tests/Integration/Repository/AccountRepositoryTest.php @@ -0,0 +1,60 @@ +accountRepository = $container->get(AccountRepository::class); + } + + #[TestDox('When you send an account and a user into findBy method, it should returns the users account list')] + #[Test] + public function findBy_WhenDataOk_ReturnsAccountList(): void + { + // ARRANGE + $user = UserFactory::createOne()->_real(); + + AccountFactory::createMany(3); + $accounts = AccountFactory::createMany(3, [ + 'user' => $user, + ]); + + // ACT + $accountResponse = $this->accountRepository->findBy([ + 'user' => $user, + ]); + + // ASSERT + self::assertCount(\count($accounts), $accountResponse); + } +} diff --git a/app/tests/Integration/Repository/BudgetRepositoryTest.php b/app/tests/Integration/Repository/BudgetRepositoryTest.php index 2de0e9c..5353bd2 100644 --- a/app/tests/Integration/Repository/BudgetRepositoryTest.php +++ b/app/tests/Integration/Repository/BudgetRepositoryTest.php @@ -25,6 +25,7 @@ final class BudgetRepositoryTest extends KernelTestCase { use Factories; use ResetDatabase; + private BudgetRepository $budgetRepository; #[\Override] diff --git a/app/tests/Integration/Repository/IncomeRepositoryTest.php b/app/tests/Integration/Repository/IncomeRepositoryTest.php index 4572ef4..6e64c17 100644 --- a/app/tests/Integration/Repository/IncomeRepositoryTest.php +++ b/app/tests/Integration/Repository/IncomeRepositoryTest.php @@ -26,6 +26,7 @@ final class IncomeRepositoryTest extends KernelTestCase { use Factories; use ResetDatabase; + private IncomeRepository $incomeRepository; #[\Override] diff --git a/app/tests/Integration/Repository/UserRepositoryTest.php b/app/tests/Integration/Repository/UserRepositoryTest.php index b4ac186..7fb4e4f 100644 --- a/app/tests/Integration/Repository/UserRepositoryTest.php +++ b/app/tests/Integration/Repository/UserRepositoryTest.php @@ -24,6 +24,7 @@ final class UserRepositoryTest extends KernelTestCase { use Factories; use ResetDatabase; + private UserRepository $userRepository; #[\Override] diff --git a/app/tests/Unit/Service/AccountServiceTest.php b/app/tests/Unit/Service/AccountServiceTest.php new file mode 100644 index 0000000..f9c946c --- /dev/null +++ b/app/tests/Unit/Service/AccountServiceTest.php @@ -0,0 +1,285 @@ +accountRepository = $this->createMock(AccountRepository::class); + $this->authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $this->security = $this->createMock(Security::class); + + $this->accountService = new AccountService( + accountRepository: $this->accountRepository, + authorizationChecker: $this->authorizationChecker, + security: $this->security, + ); + } + + #[TestDox('When calling create account, it should return the budget created')] + #[Test] + public function createAccountService_WhenDataOk_ReturnsBudgetCreated(): void + { + // ARRANGE + $account = AccountFactory::createOne([ + 'id' => 1, + 'user' => $this->security->getUser(), + ]); + + $accountPayload = (new AccountPayload()); + $accountPayload->name = 'Livret'; + + $this->accountRepository->expects($this->once()) + ->method('save') + ->willReturnCallback(static function (Account $account): void { + $account->setId(1) + ->setName('Livret') + ; + }) + ; + + // ACT + $accountResponse = $this->accountService->create($accountPayload); + + // ASSERT + self::assertInstanceOf(Account::class, $account); + self::assertSame($account->getId(), $accountResponse->getId()); + self::assertSame('Livret', $accountResponse->getName()); + } + + #[TestDox('When calling update account, it should update and return the account updated')] + #[Test] + public function updateAccountService_WhenDataOk_ReturnsAccountUpdated(): void + { + // ARRANGE + $user = UserFactory::createOne(); + + $account = AccountFactory::createOne([ + 'id' => 1, + 'user' => $user, + ]); + + $accountPayload = (new AccountPayload()); + $accountPayload->name = 'Livret updated'; + + $this->security->expects($this->any()) + ->method('getUser') + ->willReturn($user) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('edit', $account) + ->willReturn(true) + ; + + $this->accountRepository->expects($this->once()) + ->method('save') + ->willReturnCallback(static function (Account $account): void { + $account->setId(1) + ->setName('Livret updated') + ; + }) + ; + + // ACT + $accountResponse = $this->accountService->update($accountPayload, $account); + + // ASSERT + self::assertInstanceOf(Account::class, $accountResponse); + self::assertSame($account->getId(), $accountResponse->getId()); + self::assertSame('Livret updated', $accountResponse->getName()); + } + + #[TestDox('When calling update account with bad user, it should returns access denied exception')] + #[Test] + public function updateAccountService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $account = AccountFactory::createOne([ + 'id' => 1, + ]); + + $accountPayload = (new AccountPayload()); + $accountPayload->name = 'Livret updated'; + + // ACT + $this->accountService->update($accountPayload, $account); + } + + #[TestDox('When calling get account, it should get the account')] + #[Test] + public function getAccountService_WhenDataOk_ReturnsAccount(): void + { + // ARRANGE + $user = UserFactory::createOne(); + + $account = AccountFactory::createOne([ + 'id' => 1, + 'user' => $user, + ]); + + $this->security->expects($this->any()) + ->method('getUser') + ->willReturn($user) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('view', $account) + ->willReturn(true) + ; + + $this->accountRepository->expects($this->once()) + ->method('find') + ->willReturn($account) + ; + + // ACT + $accountResponse = $this->accountService->get($account->getId()); + + // ASSERT + self::assertInstanceOf(Account::class, $accountResponse); + self::assertSame($account->getId(), $accountResponse->getId()); + } + + #[TestDox('When calling get account with bad id, it should throw not found exception')] + #[Test] + public function getAccountService_WithBadId_ReturnsNotFoundException(): void + { + // ASSERT + $this->expectException(NotFoundHttpException::class); + + // ACT + $this->accountService->get(999); + } + + #[TestDox('When calling get account for another user, it should throw access denied exception')] + #[Test] + public function getAccountService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $account = AccountFactory::new()->withoutPersisting()->create(); + + $this->accountRepository->expects($this->once()) + ->method('find') + ->willReturn($account) + ; + + // ACT + $this->accountService->get($account->getId()); + } + + #[TestDox('When calling delete account, it should delete the account')] + #[Test] + public function deleteAccountService_WhenDataOk_ReturnsNoContent(): void + { + // ARRANGE + $user = UserFactory::createOne(); + + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + + $this->security->expects($this->any()) + ->method('getUser') + ->willReturn($user) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('delete', $account) + ->willReturn(true) + ; + + $this->accountRepository->expects($this->once()) + ->method('delete') + ->with($account, true) + ; + + // ACT + $this->accountService->delete($account); + + // ASSERT + self::assertInstanceOf(Account::class, $account); + } + + #[TestDox('When calling delete account with bad user, it should returns access denied exception')] + #[Test] + public function deleteAccountService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $account = AccountFactory::createOne(); + + // ACT + $this->accountService->delete($account); + } + + #[TestDox('When you call list, it should return the accounts list')] + #[Test] + public function listAccountService_WhenDataOk_ReturnsAccountsList(): void + { + // ARRANGE + AccountFactory::createMany(3); + + $accounts = AccountFactory::createMany(3, [ + 'user' => $this->security->getUser(), + ]); + + $this->accountRepository->method('findBy') + ->willReturn($accounts) + ; + + // ACT + $accountsResponse = $this->accountService->list(); + + // ASSERT + self::assertCount(\count($accounts), $accountsResponse); + } +} diff --git a/app/tests/Unit/Service/BudgetServiceTest.php b/app/tests/Unit/Service/BudgetServiceTest.php index 231d6b0..0d34153 100644 --- a/app/tests/Unit/Service/BudgetServiceTest.php +++ b/app/tests/Unit/Service/BudgetServiceTest.php @@ -62,7 +62,7 @@ protected function setUp(): void ); } - #[TestDox('When calling create budget, it should update and return the budget created')] + #[TestDox('When calling create budget, it should return the budget created')] #[Test] public function createBudgetService_WhenDataOk_ReturnsBudgetCreated(): void { @@ -72,10 +72,10 @@ public function createBudgetService_WhenDataOk_ReturnsBudgetCreated(): void 'user' => $this->security->getUser(), ]); - $BudgetPayload = (new BudgetPayload()); - $BudgetPayload->date = Carbon::parse('2022-03'); - $BudgetPayload->incomes = []; - $BudgetPayload->expenses = []; + $budgetPayload = (new BudgetPayload()); + $budgetPayload->date = Carbon::parse('2022-03'); + $budgetPayload->incomes = []; + $budgetPayload->expenses = []; $this->budgetRepository->expects($this->once()) ->method('save') @@ -88,7 +88,7 @@ public function createBudgetService_WhenDataOk_ReturnsBudgetCreated(): void ; // ACT - $budgetResponse = $this->budgetService->create($BudgetPayload); + $budgetResponse = $this->budgetService->create($budgetPayload); // ASSERT self::assertInstanceOf(Budget::class, $budget); @@ -212,12 +212,16 @@ public function deleteBudgetService_WhenDataOk_ReturnsNoContent(): void 'user' => $this->security->getUser(), ]); + $this->budgetRepository->expects($this->once()) + ->method('delete') + ->with($budget, true) + ; + // ACT - $budgetResponse = $this->budgetService->delete($budget); + $this->budgetService->delete($budget); // ASSERT self::assertInstanceOf(Budget::class, $budget); - self::assertSame($budget->getId(), $budgetResponse->getId()); } #[TestDox('When calling delete budget with bad user, it should returns access denied exception')] diff --git a/app/tests/Unit/Service/TransactionServiceTest.php b/app/tests/Unit/Service/TransactionServiceTest.php new file mode 100644 index 0000000..30263ae --- /dev/null +++ b/app/tests/Unit/Service/TransactionServiceTest.php @@ -0,0 +1,335 @@ +transactionRepository = $this->createMock(TransactionRepository::class); + $this->accountService = $this->createMock(AccountService::class); + $this->authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + + $this->transactionService = new TransactionService( + transactionRepository: $this->transactionRepository, + accountService: $this->accountService, + authorizationChecker: $this->authorizationChecker, + ); + } + + #[TestDox('When calling get transaction, it should get the transaction')] + #[Test] + public function getTransactionService_WhenDataOk_ReturnsTransaction(): void + { + // ARRANGE + $user = UserFactory::createOne(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $transaction = TransactionFactory::createOne([ + 'id' => 1, + 'account' => $account, + ]); + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('view', $transaction) + ->willReturn(true) + ; + + $this->transactionRepository->expects($this->once()) + ->method('find') + ->willReturn($transaction) + ; + + // ACT + $transactionResponse = $this->transactionService->get($transaction->getId()); + + // ASSERT + self::assertInstanceOf(Transaction::class, $transactionResponse); + self::assertSame($transaction->getId(), $transactionResponse->getId()); + } + + #[TestDox('When calling get transaction with bad id, it should throw not found exception')] + #[Test] + public function getTransactionService_WithBadId_ReturnsNotFoundException(): void + { + // ASSERT + $this->expectException(NotFoundHttpException::class); + + // ACT + $this->transactionService->get(999); + } + + #[TestDox('When calling get transaction for another user, it should throw access denied exception')] + #[Test] + public function getTransactionService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $transaction = TransactionFactory::createOne(); + + $this->transactionRepository->expects($this->once()) + ->method('find') + ->willReturn($transaction) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(false) + ; + + // ACT + $this->transactionService->get($transaction->getId()); + } + + #[TestDox('When calling create transaction, it should return the transaction created')] + #[Test] + public function createTransactionService_WhenDataOk_ReturnsTransactionCreated(): void + { + // ARRANGE + $user = UserFactory::createOne(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $transactionPayload = new TransactionPayload(); + $transactionPayload->description = 'Test transaction'; + $transactionPayload->amount = 100; + $transactionPayload->type = TransactionTypesEnum::DEBIT; + $transactionPayload->date = new \DateTime(); + + $this->accountService->expects($this->once()) + ->method('get') + ->willReturn($account) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('create', $account) + ->willReturn(true) + ; + + $this->transactionRepository->expects($this->once()) + ->method('save') + ->willReturnCallback(static function (Transaction $transaction) { + $transaction->setId(1); + }) + ; + + // ACT + $transactionResponse = $this->transactionService->create($account->getId(), $transactionPayload); + + // ASSERT + self::assertInstanceOf(Transaction::class, $transactionResponse); + self::assertSame(1, $transactionResponse->getId()); + self::assertSame('Test transaction', $transactionResponse->getDescription()); + } + + #[TestDox('When calling create transaction with non-existent account, it should throw not found exception')] + #[Test] + public function createTransactionService_WithNonExistentAccount_ReturnsNotFoundException(): void + { + // ASSERT + $this->expectException(NotFoundHttpException::class); + + // ARRANGE + $transactionPayload = new TransactionPayload(); + + $this->accountService->expects($this->once()) + ->method('get') + ->will($this->throwException(new NotFoundHttpException('Account not found'))) + ; + + // ACT + $this->transactionService->create(999, $transactionPayload); + } + + #[TestDox('When calling update transaction, it should update and return the transaction updated')] + #[Test] + public function updateTransactionService_WhenDataOk_ReturnsTransactionUpdated(): void + { + // ARRANGE + $user = UserFactory::createOne(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $transaction = TransactionFactory::createOne([ + 'id' => 1, + 'account' => $account, + ]); + + $transactionPayload = new TransactionPayload(); + $transactionPayload->description = 'Updated transaction'; + $transactionPayload->amount = 200.00; + $transactionPayload->type = TransactionTypesEnum::DEBIT; + $transactionPayload->date = new \DateTime(); + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('edit', $transaction) + ->willReturn(true) + ; + + $this->transactionRepository->expects($this->once()) + ->method('save') + ->with($transaction, true) + ; + + // ACT + $transactionResponse = $this->transactionService->update($transactionPayload, $transaction); + + // ASSERT + self::assertInstanceOf(Transaction::class, $transactionResponse); + self::assertSame('Updated transaction', $transactionResponse->getDescription()); + self::assertSame(200.00, $transactionResponse->getAmount()); + } + + #[TestDox('When calling update transaction with bad user, it should return access denied exception')] + #[Test] + public function updateTransactionService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $transaction = TransactionFactory::createOne(); + $transactionPayload = new TransactionPayload(); + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(false) + ; + + // ACT + $this->transactionService->update($transactionPayload, $transaction); + } + + #[TestDox('When calling delete transaction, it should delete the transaction')] + #[Test] + public function deleteTransactionService_WhenDataOk_ReturnsNoContent(): void + { + // ARRANGE + $user = UserFactory::createOne(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $transaction = TransactionFactory::createOne([ + 'account' => $account, + ]); + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('delete', $transaction) + ->willReturn(true) + ; + + $this->transactionRepository->expects($this->once()) + ->method('delete') + ->with($transaction, true) + ; + + // ACT + $this->transactionService->delete($transaction); + + // ASSERT + self::assertInstanceOf(Transaction::class, $transaction); + } + + #[TestDox('When calling delete transaction with bad user, it should return access denied exception')] + #[Test] + public function deleteTransactionService_WithBadUser_ReturnsAccessDeniedException(): void + { + // ASSERT + $this->expectException(AccessDeniedHttpException::class); + + // ARRANGE + $transaction = TransactionFactory::createOne(); + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(false) + ; + + // ACT + $this->transactionService->delete($transaction); + } + + #[TestDox('When you call list, it should return the transactions list')] + #[Test] + public function listTransactionService_WhenDataOk_ReturnsTransactionsList(): void + { + // ARRANGE + $user = UserFactory::createOne(); + $account = AccountFactory::createOne([ + 'user' => $user, + ]); + $transactions = TransactionFactory::createMany(3, [ + 'account' => $account, + ]); + $slidingPagination = PaginationTestHelper::getPagination($transactions); + + $this->accountService->expects($this->once()) + ->method('get') + ->willReturn($account) + ; + + $this->authorizationChecker->expects($this->once()) + ->method('isGranted') + ->with('view', $account) + ->willReturn(true) + ; + + $this->transactionRepository->method('paginate') + ->willReturn($slidingPagination) + ; + + // ACT + $transactionsResponse = $this->transactionService->paginate($account->getId(), new PaginationQueryParams()); + + // ASSERT + self::assertInstanceOf(SlidingPagination::class, $transactionsResponse); + self::assertCount(\count($transactions), $transactionsResponse->getItems()); + } +}