diff --git a/.rr.yaml b/.rr.yaml index 2fe94d4c..9649a7f5 100644 --- a/.rr.yaml +++ b/.rr.yaml @@ -13,7 +13,7 @@ http: - http_metrics - gzip pool: - num_workers: 1 + num_workers: ${RR_HTTP_NUM_WORKERS} supervisor: max_worker_memory: 100 diff --git a/app/src/Controller/Wallets/Charges/ChargesController.php b/app/src/Controller/Wallets/Charges/ChargesController.php index b16285fb..422c52a8 100644 --- a/app/src/Controller/Wallets/Charges/ChargesController.php +++ b/app/src/Controller/Wallets/Charges/ChargesController.php @@ -11,6 +11,7 @@ use App\Repository\TagRepository; use App\Repository\WalletRepository; use App\Request\Charge\CreateRequest; +use App\Request\Charge\MoveRequest; use App\Service\ChargeWalletService; use App\Service\Pagination\PaginationFactory; use App\Service\Statistics\ChargeAmountGraph; @@ -214,4 +215,44 @@ public function delete(string $walletId, string $chargeId): ResponseInterface return $this->response->create(200); } + + #[Route(route: '/wallets//charges/move/', name: 'wallet.charges.move', methods: 'POST', group: 'auth')] + public function move(string $walletId, string $targetWalletId, MoveRequest $request): ResponseInterface + { + $this->verifyIsProfileConfirmed(); + + $wallet = $this->walletRepository->findByPKByUserPK((int) $walletId, (int) $this->user->id); + + if (! $wallet instanceof Wallet) { + return $this->response->create(404); + } + + $targetWallet = $this->walletRepository->findByPKByUserPK((int) $targetWalletId, (int) $this->user->id); + + if (! $targetWallet instanceof Wallet) { + return $this->response->create(404); + } + + $charges = $this->chargeRepository->findByPKsByWalletPK($request->chargeIds, (int) $wallet->id); + + try { + $this->chargeWalletService->move($wallet, $targetWallet, $charges); + } catch (\Throwable $exception) { + $this->logger->error('Unable to move charges', [ + 'action' => 'wallet.charges.move', + 'id' => $wallet->id, + 'targetId' => $targetWallet->id, + 'chargeIds' => $request->chargeIds, + 'userId' => $this->user->id, + 'msg' => $exception->getMessage(), + ]); + + return $this->response->json([ + 'message' => $this->say('charge_update_exception'), + 'error' => $exception->getMessage(), + ], 500); + } + + return $this->response->create(200); + } } diff --git a/app/src/Database/GoogleAccount.php b/app/src/Database/GoogleAccount.php index d6f3c2b1..c886725f 100644 --- a/app/src/Database/GoogleAccount.php +++ b/app/src/Database/GoogleAccount.php @@ -48,7 +48,7 @@ public function getData(): array } try { - return (array) json_decode($this->data, true, JSON_THROW_ON_ERROR); + return (array) json_decode($this->data, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $_) { return []; } @@ -58,7 +58,7 @@ public function setData(array $data): void { try { $this->data = json_encode($data, JSON_THROW_ON_ERROR); - } catch (\JsonException $exception) { + } catch (\JsonException $_) { $this->data = ''; } } diff --git a/app/src/Repository/ChargeRepository.php b/app/src/Repository/ChargeRepository.php index 328608f4..868a6890 100644 --- a/app/src/Repository/ChargeRepository.php +++ b/app/src/Repository/ChargeRepository.php @@ -39,6 +39,19 @@ public function findByPKByWalletPK(string $chargeId, int $walletId) ->fetchOne(); } + /** + * @param array $chargeIds + * @param int $walletId + * @return array + */ + public function findByPKsByWalletPK(array $chargeIds, int $walletId) + { + return $this->select() + ->wherePK(...$chargeIds) + ->where('wallet_id', $walletId) + ->fetchAll(); + } + /** * @param int $walletId * @param int $limit diff --git a/app/src/Request/Charge/MoveRequest.php b/app/src/Request/Charge/MoveRequest.php new file mode 100644 index 00000000..720282f9 --- /dev/null +++ b/app/src/Request/Charge/MoveRequest.php @@ -0,0 +1,30 @@ + + */ + #[Data] + public array $chargeIds = []; + + public function filterDefinition(): FilterDefinitionInterface + { + return new FilterDefinition(validationRules: [ + 'chargeIds' => [ + ['array::of', ['entity:exists', Charge::class]], + ], + ]); + } +} diff --git a/app/src/Service/ChargeWalletService.php b/app/src/Service/ChargeWalletService.php index 1c60a396..6873ca7a 100644 --- a/app/src/Service/ChargeWalletService.php +++ b/app/src/Service/ChargeWalletService.php @@ -10,27 +10,12 @@ class ChargeWalletService { - /** - * @var \Cycle\ORM\EntityManagerInterface - */ - private $tr; - - /** - * ChargeWalletService constructor. - * - * @param \Cycle\ORM\EntityManagerInterface $tr - */ - public function __construct(EntityManagerInterface $tr) + const PRECISION = 2; + + public function __construct(private readonly EntityManagerInterface $tr) { - $this->tr = $tr; } - /** - * @param \App\Database\Wallet $wallet - * @param \App\Database\Charge $charge - * @return \App\Database\Charge - * @throws \Throwable - */ public function create(Wallet $wallet, Charge $charge): Charge { $wallet = $this->apply($wallet, $charge); @@ -42,13 +27,6 @@ public function create(Wallet $wallet, Charge $charge): Charge return $charge; } - /** - * @param \App\Database\Wallet $wallet - * @param \App\Database\Charge $oldCharge - * @param \App\Database\Charge $newCharge - * @return \App\Database\Charge - * @throws \Throwable - */ public function update(Wallet $wallet, Charge $oldCharge, Charge $newCharge): Charge { $wallet = $this->rollback($wallet, $oldCharge); @@ -61,11 +39,6 @@ public function update(Wallet $wallet, Charge $oldCharge, Charge $newCharge): Ch return $newCharge; } - /** - * @param \App\Database\Wallet $wallet - * @param \App\Database\Charge $charge - * @throws \Throwable - */ public function delete(Wallet $wallet, Charge $charge): void { $wallet = $this->rollback($wallet, $charge); @@ -75,21 +48,29 @@ public function delete(Wallet $wallet, Charge $charge): void $this->tr->run(); } - /** - * @param float $income - * @param float $expense - * @return float - */ + public function move(Wallet $wallet, Wallet $targetWallet, array $charges): void + { + foreach ($charges as $charge) { + if (! $charge instanceof Charge) { + continue; + } + + $this->rollback($wallet, $charge); + $this->apply($targetWallet, $charge); + $charge->setWallet($targetWallet); + $this->tr->persist($charge); + } + + $this->tr->persist($wallet); + $this->tr->persist($targetWallet); + $this->tr->run(); + } + public function totalByIncomeAndExpense(float $income, float $expense): float { - return $income - $expense; + return $this->safeFloatNumber($income - $expense); } - /** - * @param \App\Database\Wallet $wallet - * @param \App\Database\Charge $charge - * @return \App\Database\Wallet - */ protected function apply(Wallet $wallet, Charge $charge): Wallet { switch ($charge->type) { @@ -101,14 +82,11 @@ protected function apply(Wallet $wallet, Charge $charge): Wallet break; } + $wallet->totalAmount = $this->safeFloatNumber($wallet->totalAmount); + return $wallet; } - /** - * @param \App\Database\Wallet $wallet - * @param \App\Database\Charge $charge - * @return \App\Database\Wallet - */ protected function rollback(Wallet $wallet, Charge $charge): Wallet { switch ($charge->type) { @@ -120,6 +98,13 @@ protected function rollback(Wallet $wallet, Charge $charge): Wallet break; } + $wallet->totalAmount = $this->safeFloatNumber($wallet->totalAmount); + return $wallet; } + + protected function safeFloatNumber(float $number): float + { + return round($number, self::PRECISION); + } } diff --git a/tests/Feature/Bootloader/GoogleApiBootloaderTest.php b/tests/Feature/Bootloader/GoogleApiBootloaderTest.php new file mode 100644 index 00000000..6b4794ba --- /dev/null +++ b/tests/Feature/Bootloader/GoogleApiBootloaderTest.php @@ -0,0 +1,37 @@ +getContainer()->get(GoogleApiConfig::class); + + $class = new \ReflectionClass($config); + $class->getProperty('config')->setValue($config, [ + 'clientId' => Fixtures::string(), + 'clientSecret' => Fixtures::string(), + 'projectId' => Fixtures::string(), + 'authUri' => Fixtures::url(), + 'tokenUri' => Fixtures::url(), + 'authProviderX509CertUrl' => Fixtures::url(), + 'redirectUris' => [Fixtures::url()], + ]); + + $bootloader = new GoogleApiBootloader($config); + $bootloader->boot($this->getContainer()); + + $client = $this->getContainer()->get(Client::class); + + $this->assertInstanceOf(Client::class, $client); + } +} diff --git a/tests/Feature/Config/GoogleApiConfigTest.php b/tests/Feature/Config/GoogleApiConfigTest.php new file mode 100644 index 00000000..b5ff702e --- /dev/null +++ b/tests/Feature/Config/GoogleApiConfigTest.php @@ -0,0 +1,37 @@ +getContainer()->get(GoogleApiConfig::class); + + $class = new \ReflectionClass($config); + $class->getProperty('config')->setValue($config, [ + 'clientId' => Fixtures::string(), + 'clientSecret' => Fixtures::string(), + 'projectId' => Fixtures::string(), + 'authUri' => Fixtures::url(), + 'tokenUri' => Fixtures::url(), + 'authProviderX509CertUrl' => Fixtures::url(), + 'redirectUris' => [Fixtures::url()], + ]); + + $this->assertNotEmpty($config->getClientId()); + $this->assertNotEmpty($config->getClientSecret()); + $this->assertNotEmpty($config->getProjectId()); + $this->assertNotEmpty($config->getAuthUri()); + $this->assertNotEmpty($config->getTokenUri()); + $this->assertNotEmpty($config->getAuthProviderX509CertUrl()); + $this->assertNotEmpty($config->getRedirectUris()); + } +} diff --git a/tests/Feature/Controller/Wallets/Charges/ChargesControllerTest.php b/tests/Feature/Controller/Wallets/Charges/ChargesControllerTest.php index 4bfedf55..42ffb81b 100644 --- a/tests/Feature/Controller/Wallets/Charges/ChargesControllerTest.php +++ b/tests/Feature/Controller/Wallets/Charges/ChargesControllerTest.php @@ -1064,4 +1064,134 @@ public function testDeleteThrownException(): void $this->assertArrayHasKey('error', $body); $this->assertArrayHasKey('message', $body); } + + public function testMoveRequireAuth(): void + { + $user = $this->userFactory->create(); + $wallet = $this->walletFactory->forUser($user)->create(); + $charges = $this->chargeFactory->forUser($user)->forWallet($wallet)->createMany(10); + $targetWallet = $this->walletFactory->forUser($user)->create(); + + $response = $this->post("/wallets/{$wallet->id}/charges/move/{$targetWallet->id}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertUnauthorized(); + } + + public function testMoveMissingWalletsRequireAuth(): void + { + $walletId = Fixtures::integer(); + $targetWalletId = Fixtures::integer(); + $chargeId = Fixtures::string(); + + $response = $this->post("/wallets/{$walletId}/charges/move/{$targetWalletId}", [ + 'chargeIds' => [$chargeId], + ]); + + $response->assertUnauthorized(); + } + + public function testMoveValidationFails(): void + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + $wallet = $this->walletFactory->forUser($user)->create(); + $targetWallet = $this->walletFactory->forUser($user)->create(); + $chargeId = Fixtures::string(); + + $response = $this->withAuth($auth)->post("/wallets/{$wallet->id}/charges/move/{$targetWallet->id}", [ + 'chargeIds' => [$chargeId], + ]); + + $response->assertUnprocessable(); + } + + public function testMoveMissingWalletsReturnNotFound(): void + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + $wallet = $this->walletFactory->forUser($user)->create(); + $charges = $this->chargeFactory->forUser($user)->forWallet($wallet)->createMany(10); + $walletId = Fixtures::integer(); + $targetWalletId = Fixtures::integer(); + + $response = $this->withAuth($auth)->post("/wallets/{$walletId}/charges/move/{$targetWalletId}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertNotFound(); + } + + public function testMoveNonMemberReturnNotFound(): void + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + + $foreignWallet = $this->walletFactory->create(); + $foreignTargetWallet = $this->walletFactory->create(); + + $wallet = $this->walletFactory->forUser($user)->create(); + $charges = $this->chargeFactory->forUser($user)->forWallet($wallet)->createMany(10); + + $response = $this->withAuth($auth)->post("/wallets/{$foreignWallet->id}/charges/move/{$foreignTargetWallet->id}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertNotFound(); + + $response = $this->withAuth($auth)->post("/wallets/{$wallet->id}/charges/move/{$foreignTargetWallet->id}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertNotFound(); + } + + public function testMoveChangeChargesWallet(): void + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + $wallet = $this->walletFactory->forUser($user)->create(); + $targetWallet = $this->walletFactory->forUser($user)->create(); + $charges = $this->chargeFactory->forUser($user)->forWallet($wallet)->createMany(10); + + $response = $this->withAuth($auth)->post("/wallets/{$wallet->id}/charges/move/{$targetWallet->id}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertOk(); + + foreach ($charges as $charge) { + /** @var \App\Database\Charge $charge */ + $this->assertDatabaseMissing('charges', [ + 'id' => $charge->id, + 'wallet_id' => $wallet->id, + ]); + + $this->assertDatabaseHas('charges', [ + 'id' => $charge->id, + 'wallet_id' => $targetWallet->id, + ]); + } + } + + public function testMoveThrownException(): void + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + + $wallet = $this->walletFactory->forUser($user)->create(); + $charges = $this->chargeFactory->forUser($user)->forWallet($wallet)->createMany(10); + $targetWallet = $this->walletFactory->forUser($user)->create(); + + $this->mock(ChargeWalletService::class, ['move'], function (MockObject $mock) { + $mock->expects($this->once())->method('move')->willThrowException(new \RuntimeException()); + }); + + $response = $this->withAuth($auth)->post("/wallets/{$wallet->id}/charges/move/{$targetWallet->id}", [ + 'chargeIds' => $charges->map(fn (Charge $charge) => $charge->id)->toArray(), + ]); + + $response->assertStatus(500); + + $body = $this->getJsonResponseBody($response); + + $this->assertArrayHasKey('error', $body); + $this->assertArrayHasKey('message', $body); + } } diff --git a/tests/Feature/Database/Encrypter/EncrypterTest.php b/tests/Feature/Database/Encrypter/EncrypterTest.php index 1a5f72f8..2f6bb0f1 100644 --- a/tests/Feature/Database/Encrypter/EncrypterTest.php +++ b/tests/Feature/Database/Encrypter/EncrypterTest.php @@ -6,11 +6,28 @@ use App\Config\AppConfig; use App\Database\Encrypter\Encrypter; +use Spiral\Encrypter\Exception\EncrypterException; use Tests\Fixtures; use Tests\TestCase; class EncrypterTest extends TestCase { + public function testEnabled(): void + { + $key = Fixtures::string(); + + $config = $this->getMockBuilder(AppConfig::class)->onlyMethods(['getDbEncrypterKey'])->getMock(); + $config->method('getDbEncrypterKey')->willReturn($key); + + $encrypter = new Encrypter($config); + + $string = Fixtures::string(); + $encrypted = $encrypter->encrypt($string); + + $this->assertNotEquals($string, $encrypted); + $this->assertEquals($string, $encrypter->decrypt($encrypted)); + } + public function testDisabled(): void { $config = $this->getMockBuilder(AppConfig::class)->onlyMethods(['getDbEncrypterKey'])->getMock(); @@ -23,4 +40,25 @@ public function testDisabled(): void $this->assertEquals($string, $encrypter->encrypt($string)); $this->assertEquals($string, $encrypter->decrypt($string)); } + + public function testDecryptThrowException(): void + { + $key = Fixtures::string(); + + $config = $this->getMockBuilder(AppConfig::class)->onlyMethods(['getDbEncrypterKey'])->getMock(); + $config->method('getDbEncrypterKey')->willReturn($key); + + $encrypter = new Encrypter($config); + + $string = Fixtures::string(); + $encrypted = $encrypter->encrypt($string); + + $this->assertNotEquals($string, $encrypted); + + $this->expectException(EncrypterException::class); + + $encrypted .= Fixtures::string(1); + + $this->assertNotEquals($string, $encrypter->decrypt($encrypted)); + } } diff --git a/tests/Feature/Database/GoogleAccountTest.php b/tests/Feature/Database/GoogleAccountTest.php new file mode 100644 index 00000000..1bcc818c --- /dev/null +++ b/tests/Feature/Database/GoogleAccountTest.php @@ -0,0 +1,29 @@ +assertEquals([], $entity->getData()); + + $entity->data = '{one:two}'; + $this->assertEquals([], $entity->getData()); + + $data = ['googleId' => Fixtures::integer(), 'photoUrl' => Fixtures::url()]; + $entity->setData($data); + $this->assertEquals($data, $entity->getData()); + + $entity->setData(['key' => fopen('/dev/null', 'r')]); + $this->assertEquals([], $entity->getData()); + } +} diff --git a/tests/Feature/Service/ChargeWalletServiceTest.php b/tests/Feature/Service/ChargeWalletServiceTest.php index 60559e4c..e974d7d0 100644 --- a/tests/Feature/Service/ChargeWalletServiceTest.php +++ b/tests/Feature/Service/ChargeWalletServiceTest.php @@ -92,4 +92,57 @@ public function testDelete(string $type, float $totalAmount, float $chargeAmount $this->assertEquals($expectedTotal, $wallet->totalAmount); } + + public function moveDataProvider(): array + { + $charge1 = ChargeFactory::income(); + $charge1->amount = 1.99; + + $charge2 = ChargeFactory::expense(); + $charge2->amount = 1.99; + + $charge3 = ChargeFactory::expense(); + $charge3->amount = 1.01; + + return [ + [[3.00, 1.01], [1.01, 3.00], [$charge1, null]], + [[3.00, 4.99], [2.01, 0.02], [$charge2, 1]], + [[3.01, 2.03], [2.02, 3.00], [$charge1, $charge3]], + ]; + } + + /** + * @dataProvider moveDataProvider + * @param array $walletAmounts + * @param array $targetWalletAmounts + * @param array $charges + * @return void + */ + public function testMove(array $walletAmounts, array $targetWalletAmounts, array $charges) + { + $service = new ChargeWalletService( + $this->getMockBuilder(EntityManagerInterface::class)->getMock() + ); + + $wallet = WalletFactory::make(); + $wallet->totalAmount = $walletAmounts[0]; + + $targetWallet = WalletFactory::make(); + $targetWallet->totalAmount = $targetWalletAmounts[0]; + + $charge = ChargeFactory::income(); + $charge->type = Charge::TYPE_INCOME; + $charge->amount = 1.99; + + $service->move($wallet, $targetWallet, $charges); + + $this->assertEquals($walletAmounts[1], $wallet->totalAmount); + $this->assertEquals($targetWalletAmounts[1], $targetWallet->totalAmount); + + foreach ($charges as $charge) { + if ($charge instanceof Charge) { + $this->assertEquals($charge->walletId, $targetWallet->id); + } + } + } }