From b474eadc134a1dad0b53bd6d1cd2e8afd1015955 Mon Sep 17 00:00:00 2001 From: Michael Hoffmann Date: Fri, 12 Sep 2025 15:25:53 +0200 Subject: [PATCH 1/2] fix(backend): Correctly calculate spendable potato --- src/Model/Entity/User.php | 38 +++++- tests/Fixture/PurchasesFixture.php | 83 ++++++++++++ tests/TestCase/Model/Entity/UserTest.php | 162 +++++++++++++++++++++++ 3 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 tests/Fixture/PurchasesFixture.php diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index e996e99b..6755273a 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -48,6 +48,8 @@ class User extends Entity public const ROLE_USER = 'user'; public const ROLE_SERVICE = 'service'; + public const SPENDING_LIMIT_90_DAYS = 500; + /** * @param array|null $notifications The user's notification settings. * @return array @@ -209,24 +211,46 @@ public function potatoResetInMinutes(): string */ public function spendablePotato(): int { - $pruchasesTable = $this->fetchTable('Purchases'); + $purchasesTable = $this->fetchTable('Purchases'); - $query = $pruchasesTable->find(); - $result = $query + // Calculate total spent by this user (all time) + $totalSpentQuery = $purchasesTable->find(); + $totalSpentResult = $totalSpentQuery ->select([ - 'spent' => $query->func()->sum('price'), + 'spent' => $totalSpentQuery->func()->sum('price'), ]) ->where([ 'user_id' => $this->id, ]) ->first(); + $totalSpent = (int)$totalSpentResult->spent; - // @FIXME make this based on 90 days - if ((int)$result->spent >= 500) { + // Calculate spent in the last 90 days + $ninetyDaysAgo = DateTime::now()->subDays(90); + $last90DaysQuery = $purchasesTable->find(); + $last90DaysResult = $last90DaysQuery + ->select([ + 'spent' => $last90DaysQuery->func()->sum('price'), + ]) + ->where([ + 'user_id' => $this->id, + 'created >=' => $ninetyDaysAgo, + ]) + ->first(); + $spentLast90Days = (int)$last90DaysResult->spent; + + // Check if the user has reached the spending limit in the last 90 days + if ($spentLast90Days >= self::SPENDING_LIMIT_90_DAYS) { return 0; } - return $this->potatoReceived() - (int)$result->spent; + // Calculate available balance (total received minus total spent) + $availableBalance = $this->potatoReceived() - $totalSpent; + + // Return the minimum of available balance or remaining 90-day limit + $remainingLimit = self::SPENDING_LIMIT_90_DAYS - $spentLast90Days; + + return min($availableBalance, $remainingLimit); } /** diff --git a/tests/Fixture/PurchasesFixture.php b/tests/Fixture/PurchasesFixture.php new file mode 100644 index 00000000..80723d0f --- /dev/null +++ b/tests/Fixture/PurchasesFixture.php @@ -0,0 +1,83 @@ +> + */ + public array $records = [ + // User 1 purchases + [ + 'id' => 1, + 'user_id' => '00000000-0000-0000-0000-000000000001', + 'presentee_id' => null, + 'name' => 'Old Purchase', + 'description' => 'Purchase from 120 days ago', + 'image_link' => 'https://example.com/old.jpg', + 'message' => null, + 'price' => 100, + 'code' => null, + 'created' => '2025-05-15 10:00:00', // ~120 days ago from Sept 12, 2025 + ], + [ + 'id' => 2, + 'user_id' => '00000000-0000-0000-0000-000000000001', + 'presentee_id' => null, + 'name' => 'Recent Purchase 1', + 'description' => 'Purchase from 30 days ago', + 'image_link' => 'https://example.com/recent1.jpg', + 'message' => null, + 'price' => 200, + 'code' => null, + 'created' => '2025-08-13 10:00:00', // ~30 days ago + ], + [ + 'id' => 3, + 'user_id' => '00000000-0000-0000-0000-000000000001', + 'presentee_id' => null, + 'name' => 'Recent Purchase 2', + 'description' => 'Purchase from 10 days ago', + 'image_link' => 'https://example.com/recent2.jpg', + 'message' => null, + 'price' => 150, + 'code' => null, + 'created' => '2025-09-02 10:00:00', // ~10 days ago + ], + // User 2 purchases (at spending limit) + [ + 'id' => 4, + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'presentee_id' => null, + 'name' => 'Max Purchase 1', + 'description' => 'Purchase from 60 days ago', + 'image_link' => 'https://example.com/max1.jpg', + 'message' => null, + 'price' => 300, + 'code' => null, + 'created' => '2025-07-14 10:00:00', // ~60 days ago + ], + [ + 'id' => 5, + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'presentee_id' => null, + 'name' => 'Max Purchase 2', + 'description' => 'Purchase from 20 days ago', + 'image_link' => 'https://example.com/max2.jpg', + 'message' => null, + 'price' => 200, + 'code' => null, + 'created' => '2025-08-23 10:00:00', // ~20 days ago + ], + // User 3 has no purchases + ]; +} diff --git a/tests/TestCase/Model/Entity/UserTest.php b/tests/TestCase/Model/Entity/UserTest.php index a3d68683..3df7e59f 100644 --- a/tests/TestCase/Model/Entity/UserTest.php +++ b/tests/TestCase/Model/Entity/UserTest.php @@ -18,6 +18,8 @@ class UserTest extends TestCase */ protected array $fixtures = [ 'app.Users', + 'app.Messages', + 'app.Purchases', ]; /** @@ -121,4 +123,164 @@ public function testPotatoResetInHours(): void $this->assertSame('15', $this->UserCanada->potatoResetInHours()); $this->assertSame('18', $this->UserUS->potatoResetInHours()); } + + /** + * Test spendablePotato method with no purchases and no received potatoes + * + * @return void + * @uses \App\Model\Entity\User::spendablePotato() + */ + public function testSpendablePotatoNoPurchasesNoReceived(): void + { + // User 3 has no purchases and no received potatoes + $spendable = $this->UserUS->spendablePotato(); + $this->assertSame(0, $spendable); + } + + /** + * Test spendablePotato method with recent purchases within 90-day limit + * + * @return void + * @uses \App\Model\Entity\User::spendablePotato() + */ + public function testSpendablePotatoWithRecentPurchases(): void + { + Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC')); + + // Add some received potatoes for User 1 + $messagesTable = $this->fetchTable('Messages'); + $message = $messagesTable->newEntity([ + 'id' => 'test-msg-001', + 'sender_user_id' => '00000000-0000-0000-0000-000000000002', + 'receiver_user_id' => '00000000-0000-0000-0000-000000000001', + 'amount' => 600, + 'type' => 'potato', + 'created' => new Chronos('2025-08-01 10:00:00'), + ], ['accessibleFields' => ['*' => true]]); + $messagesTable->saveOrFail($message); + + // User 1 has received 600 potatoes + // User 1 has spent 100 (>90 days ago) + 200 (30 days ago) + 150 (10 days ago) = 450 total + // Only 200 + 150 = 350 in last 90 days + // Available balance: 600 - 450 = 150 + // Remaining 90-day limit: 500 - 350 = 150 + // Should return min(150, 150) = 150 + $spendable = $this->UserEurope->spendablePotato(); + $this->assertSame(150, $spendable); + } + + /** + * Test spendablePotato method when user has reached 90-day spending limit + * + * @return void + * @uses \App\Model\Entity\User::spendablePotato() + */ + public function testSpendablePotatoAtSpendingLimit(): void + { + Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC')); + + // Add some received potatoes for User 2 + $messagesTable = $this->fetchTable('Messages'); + $message = $messagesTable->newEntity([ + 'id' => 'test-msg-002', + 'sender_user_id' => '00000000-0000-0000-0000-000000000001', + 'receiver_user_id' => '00000000-0000-0000-0000-000000000002', + 'amount' => 800, + 'type' => 'potato', + 'created' => new Chronos('2025-08-01 10:00:00'), + ], ['accessibleFields' => ['*' => true]]); + $messagesTable->saveOrFail($message); + + // User 2 has received 800 potatoes + // User 2 has spent 300 (60 days ago) + 200 (20 days ago) = 500 in last 90 days + // Has reached the 500 limit + $spendable = $this->UserCanada->spendablePotato(); + $this->assertSame(0, $spendable); + } + + /** + * Test spendablePotato method with old purchases outside 90-day window + * + * @return void + * @uses \App\Model\Entity\User::spendablePotato() + */ + public function testSpendablePotatoWithOldPurchases(): void + { + Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC')); + + // Create a user with only old purchases + $purchasesTable = $this->fetchTable('Purchases'); + $purchase = $purchasesTable->newEntity([ + 'user_id' => '00000000-0000-0000-0000-000000000003', + 'name' => 'Very Old Purchase', + 'description' => 'Purchase from 100 days ago', + 'image_link' => 'https://example.com/old.jpg', + 'price' => 400, + 'created' => new Chronos('2025-06-04 10:00:00'), // > 90 days ago + ], ['accessibleFields' => ['*' => true]]); + $purchasesTable->saveOrFail($purchase); + + // Add received potatoes + $messagesTable = $this->fetchTable('Messages'); + $message = $messagesTable->newEntity([ + 'id' => 'test-msg-003', + 'sender_user_id' => '00000000-0000-0000-0000-000000000001', + 'receiver_user_id' => '00000000-0000-0000-0000-000000000003', + 'amount' => 600, + 'type' => 'potato', + 'created' => new Chronos('2025-08-01 10:00:00'), + ], ['accessibleFields' => ['*' => true]]); + $messagesTable->saveOrFail($message); + + // User 3 has received 600 potatoes + // User 3 has spent 400 total, but 0 in last 90 days + // Available balance: 600 - 400 = 200 + // Remaining 90-day limit: 500 - 0 = 500 + // Should return min(200, 500) = 200 + $spendable = $this->UserUS->spendablePotato(); + $this->assertSame(200, $spendable); + } + + /** + * Test spendablePotato method when balance is less than 90-day limit + * + * @return void + * @uses \App\Model\Entity\User::spendablePotato() + */ + public function testSpendablePotatoLimitedByBalance(): void + { + Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC')); + + // Create a scenario where available balance is less than 90-day limit + $purchasesTable = $this->fetchTable('Purchases'); + $purchase = $purchasesTable->newEntity([ + 'user_id' => '00000000-0000-0000-0000-000000000003', + 'name' => 'Recent Small Purchase', + 'description' => 'Small purchase from 5 days ago', + 'image_link' => 'https://example.com/small.jpg', + 'price' => 50, + 'created' => new Chronos('2025-09-07 10:00:00'), // 5 days ago + ], ['accessibleFields' => ['*' => true]]); + $purchasesTable->saveOrFail($purchase); + + // Add limited received potatoes + $messagesTable = $this->fetchTable('Messages'); + $message = $messagesTable->newEntity([ + 'id' => 'test-msg-004', + 'sender_user_id' => '00000000-0000-0000-0000-000000000001', + 'receiver_user_id' => '00000000-0000-0000-0000-000000000003', + 'amount' => 100, + 'type' => 'potato', + 'created' => new Chronos('2025-08-01 10:00:00'), + ], ['accessibleFields' => ['*' => true]]); + $messagesTable->saveOrFail($message); + + // User 3 has received 100 potatoes + // User 3 has spent 50 in last 90 days + // Available balance: 100 - 50 = 50 + // Remaining 90-day limit: 500 - 50 = 450 + // Should return min(50, 450) = 50 (limited by balance) + $spendable = $this->UserUS->spendablePotato(); + $this->assertSame(50, $spendable); + } } From c4c5ebb0b634b431911714714f0bcc793261a48e Mon Sep 17 00:00:00 2001 From: Michael Hoffmann Date: Fri, 12 Sep 2025 20:14:22 +0200 Subject: [PATCH 2/2] wip --- tests/TestCase/Model/Entity/UserTest.php | 38 +++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/TestCase/Model/Entity/UserTest.php b/tests/TestCase/Model/Entity/UserTest.php index 3df7e59f..83abbc68 100644 --- a/tests/TestCase/Model/Entity/UserTest.php +++ b/tests/TestCase/Model/Entity/UserTest.php @@ -3,8 +3,10 @@ namespace App\Test\TestCase\Model\Entity; +use App\Model\Entity\Message; use Cake\Chronos\Chronos; use Cake\TestSuite\TestCase; +use Cake\Utility\Text; /** * App\Model\Entity\User Test Case @@ -150,11 +152,11 @@ public function testSpendablePotatoWithRecentPurchases(): void // Add some received potatoes for User 1 $messagesTable = $this->fetchTable('Messages'); $message = $messagesTable->newEntity([ - 'id' => 'test-msg-001', - 'sender_user_id' => '00000000-0000-0000-0000-000000000002', - 'receiver_user_id' => '00000000-0000-0000-0000-000000000001', + 'id' => Text::uuid(), + 'sender_user_id' => $this->UserCanada->id, + 'receiver_user_id' => $this->UserEurope->id, 'amount' => 600, - 'type' => 'potato', + 'type' => Message::TYPE_POTATO, 'created' => new Chronos('2025-08-01 10:00:00'), ], ['accessibleFields' => ['*' => true]]); $messagesTable->saveOrFail($message); @@ -182,11 +184,11 @@ public function testSpendablePotatoAtSpendingLimit(): void // Add some received potatoes for User 2 $messagesTable = $this->fetchTable('Messages'); $message = $messagesTable->newEntity([ - 'id' => 'test-msg-002', - 'sender_user_id' => '00000000-0000-0000-0000-000000000001', - 'receiver_user_id' => '00000000-0000-0000-0000-000000000002', + 'id' => Text::uuid(), + 'sender_user_id' => $this->UserEurope->id, + 'receiver_user_id' => $this->UserCanada->id, 'amount' => 800, - 'type' => 'potato', + 'type' => Message::TYPE_POTATO, 'created' => new Chronos('2025-08-01 10:00:00'), ], ['accessibleFields' => ['*' => true]]); $messagesTable->saveOrFail($message); @@ -211,7 +213,7 @@ public function testSpendablePotatoWithOldPurchases(): void // Create a user with only old purchases $purchasesTable = $this->fetchTable('Purchases'); $purchase = $purchasesTable->newEntity([ - 'user_id' => '00000000-0000-0000-0000-000000000003', + 'user_id' => $this->UserUS->id, 'name' => 'Very Old Purchase', 'description' => 'Purchase from 100 days ago', 'image_link' => 'https://example.com/old.jpg', @@ -223,11 +225,11 @@ public function testSpendablePotatoWithOldPurchases(): void // Add received potatoes $messagesTable = $this->fetchTable('Messages'); $message = $messagesTable->newEntity([ - 'id' => 'test-msg-003', - 'sender_user_id' => '00000000-0000-0000-0000-000000000001', - 'receiver_user_id' => '00000000-0000-0000-0000-000000000003', + 'id' => Text::uuid(), + 'sender_user_id' => $this->UserEurope->id, + 'receiver_user_id' => $this->UserUS->id, 'amount' => 600, - 'type' => 'potato', + 'type' => Message::TYPE_POTATO, 'created' => new Chronos('2025-08-01 10:00:00'), ], ['accessibleFields' => ['*' => true]]); $messagesTable->saveOrFail($message); @@ -254,7 +256,7 @@ public function testSpendablePotatoLimitedByBalance(): void // Create a scenario where available balance is less than 90-day limit $purchasesTable = $this->fetchTable('Purchases'); $purchase = $purchasesTable->newEntity([ - 'user_id' => '00000000-0000-0000-0000-000000000003', + 'user_id' => $this->UserUS->id, 'name' => 'Recent Small Purchase', 'description' => 'Small purchase from 5 days ago', 'image_link' => 'https://example.com/small.jpg', @@ -266,11 +268,11 @@ public function testSpendablePotatoLimitedByBalance(): void // Add limited received potatoes $messagesTable = $this->fetchTable('Messages'); $message = $messagesTable->newEntity([ - 'id' => 'test-msg-004', - 'sender_user_id' => '00000000-0000-0000-0000-000000000001', - 'receiver_user_id' => '00000000-0000-0000-0000-000000000003', + 'id' => Text::uuid(), + 'sender_user_id' => $this->UserCanada->id, + 'receiver_user_id' => $this->UserUS->id, 'amount' => 100, - 'type' => 'potato', + 'type' => Message::TYPE_POTATO, 'created' => new Chronos('2025-08-01 10:00:00'), ], ['accessibleFields' => ['*' => true]]); $messagesTable->saveOrFail($message);