diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a2c6ae..0d94fdc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: run: php artisan key:generate - name: Tests - run: ./vendor/bin/pest --exclude-testsuite=Browser + run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 @@ -155,9 +155,58 @@ jobs: - name: Frontend Tests run: bun run test + performance-tests: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: testing + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + tools: composer:v2 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Node Dependencies + run: bun install --frozen-lockfile + + - name: Install Dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Build Assets + run: bun run build + + - name: Copy Environment File + run: cp .env.example .env + + - name: Generate Application Key + run: php artisan key:generate + + - name: Performance Tests + run: ./vendor/bin/pest --testsuite=Performance + env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: testing + DB_USERNAME: root + DB_PASSWORD: password + build-image: runs-on: ubuntu-latest - needs: [tests, linter] + needs: [tests, linter, performance-tests] if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch' permissions: contents: read @@ -201,7 +250,7 @@ jobs: deploy: runs-on: ubuntu-latest - needs: [tests, linter] + needs: [tests, linter, performance-tests] if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Trigger deployment @@ -227,4 +276,3 @@ jobs: fi echo "Deployment triggered successfully!" - diff --git a/phpunit.xml b/phpunit.xml index 5b52d429..1ce6f296 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,9 @@ tests/Browser + + tests/Performance + diff --git a/tests/Performance/ApiQueryCountTest.php b/tests/Performance/ApiQueryCountTest.php new file mode 100644 index 00000000..0d150b84 --- /dev/null +++ b/tests/Performance/ApiQueryCountTest.php @@ -0,0 +1,111 @@ +user = performanceSeedUser(); + $this->actingAs($this->user); + + $this->dateParams = http_build_query([ + 'from' => now()->subDays(30)->toDateString(), + 'to' => now()->toDateString(), + ]); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Dashboard Analytics API +// ────────────────────────────────────────────────────────────────────────── + +test('net worth API does not exceed query threshold', function () { + assertMaxQueries(25, function () { + $this->getJson("/api/dashboard/net-worth?{$this->dateParams}")->assertOk(); + }, 'API Net Worth'); +}); + +test('monthly spending API does not exceed query threshold', function () { + assertMaxQueries(12, function () { + $this->getJson("/api/dashboard/monthly-spending?{$this->dateParams}")->assertOk(); + }, 'API Monthly Spending'); +}); + +test('cash flow API does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->getJson("/api/dashboard/cash-flow?{$this->dateParams}")->assertOk(); + }, 'API Cash Flow'); +}); + +test('top categories API does not exceed query threshold', function () { + assertMaxQueries(12, function () { + $this->getJson("/api/dashboard/top-categories?{$this->dateParams}")->assertOk(); + }, 'API Top Categories'); +}); + +test('net worth evolution API does not exceed query threshold', function () { + assertMaxQueries(25, function () { + $this->getJson("/api/dashboard/net-worth-evolution?{$this->dateParams}")->assertOk(); + }, 'API Net Worth Evolution'); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Cashflow Analytics API +// ────────────────────────────────────────────────────────────────────────── + +test('cashflow summary API does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->getJson("/api/cashflow/summary?{$this->dateParams}")->assertOk(); + }, 'API Cashflow Summary'); +}); + +test('cashflow trend API does not exceed query threshold', function () { + assertMaxQueries(35, function () { + $this->getJson('/api/cashflow/trend')->assertOk(); + }, 'API Cashflow Trend'); +}); + +test('cashflow breakdown API does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->getJson("/api/cashflow/breakdown?type=expense&{$this->dateParams}")->assertOk(); + }, 'API Cashflow Breakdown'); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Query count must not scale with data volume +// ────────────────────────────────────────────────────────────────────────── + +test('net worth API query count does not scale with number of accounts', function () { + $categories = $this->user->categories; + + $extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]); + foreach ($extraAccounts as $index => $account) { + for ($i = 0; $i < 5; $i++) { + App\Models\AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(), + ]); + } + } + + // The net worth endpoint queries per-account, so we allow proportional growth + // but cap it to prevent unbounded scaling + assertMaxQueries(55, function () { + $this->getJson("/api/dashboard/net-worth?{$this->dateParams}")->assertOk(); + }, 'API Net Worth with 10 accounts'); +}); + +test('cashflow trend API query count does not scale with extra months', function () { + assertMaxQueries(35, function () { + $this->getJson('/api/cashflow/trend?months=12')->assertOk(); + }, 'API Cashflow Trend 12 months'); +}); diff --git a/tests/Performance/PageQueryCountTest.php b/tests/Performance/PageQueryCountTest.php new file mode 100644 index 00000000..6c3be1e2 --- /dev/null +++ b/tests/Performance/PageQueryCountTest.php @@ -0,0 +1,161 @@ +user = performanceSeedUser(); + $this->actingAs($this->user); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Main Sidebar Pages +// ────────────────────────────────────────────────────────────────────────── + +test('dashboard page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('dashboard'))->assertOk(); + }, 'Dashboard'); +}); + +test('accounts index page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('accounts.list'))->assertOk(); + }, 'Accounts Index'); +}); + +test('account show page does not exceed query threshold', function () { + $account = $this->user->accounts()->first(); + + assertMaxQueries(17, function () use ($account) { + $this->get(route('accounts.show', $account))->assertOk(); + }, 'Account Show'); +}); + +test('transactions index page does not exceed query threshold', function () { + assertMaxQueries(20, function () { + $this->get(route('transactions.index'))->assertOk(); + }, 'Transactions Index'); +}); + +test('budgets index page does not exceed query threshold', function () { + assertMaxQueries(18, function () { + $this->get(route('budgets.index'))->assertOk(); + }, 'Budgets Index'); +}); + +test('budget show page does not exceed query threshold', function () { + $budget = $this->user->budgets()->first(); + + assertMaxQueries(20, function () use ($budget) { + $this->get(route('budgets.show', $budget))->assertOk(); + }, 'Budget Show'); +}); + +test('cashflow page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('cashflow'))->assertOk(); + }, 'Cashflow'); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Settings Pages +// ────────────────────────────────────────────────────────────────────────── + +test('settings accounts page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('accounts.index'))->assertOk(); + }, 'Settings Accounts'); +}); + +test('settings categories page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('categories.index'))->assertOk(); + }, 'Settings Categories'); +}); + +test('settings labels page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('labels.index'))->assertOk(); + }, 'Settings Labels'); +}); + +test('settings automation rules page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('automation-rules.index'))->assertOk(); + }, 'Settings Automation Rules'); +}); + +test('settings account/profile page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('account.edit'))->assertOk(); + }, 'Settings Account/Profile'); +}); + +test('settings appearance page does not exceed query threshold', function () { + assertMaxQueries(15, function () { + $this->get(route('appearance.edit'))->assertOk(); + }, 'Settings Appearance'); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Query count must not scale with data volume +// ────────────────────────────────────────────────────────────────────────── + +test('dashboard query count does not scale with number of accounts', function () { + // Add 7 more accounts (10 total) with transactions and balances + $categories = $this->user->categories; + + $extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]); + foreach ($extraAccounts as $index => $account) { + App\Models\Transaction::factory(10)->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $categories->random()->id, + ]); + + for ($i = 0; $i < 5; $i++) { + App\Models\AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(), + ]); + } + } + + // Same threshold as 3 accounts — query count must not grow with data + assertMaxQueries(15, function () { + $this->get(route('dashboard'))->assertOk(); + }, 'Dashboard with 10 accounts'); +}); + +test('transactions page query count does not scale with number of transactions', function () { + $account = $this->user->accounts()->first(); + $category = $this->user->categories()->first(); + + // Add 90 more transactions (120 total) + App\Models\Transaction::factory(90)->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + ]); + + // Same threshold — paginated queries should not scale + assertMaxQueries(20, function () { + $this->get(route('transactions.index'))->assertOk(); + }, 'Transactions with 120 records'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 7f9c989b..6c67ad1f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature', 'Browser'); + ->in('Feature', 'Browser', 'Performance'); pest()->browser()->timeout(15000); @@ -43,6 +43,84 @@ pest()->browser()->timeout(15000); | */ +/** + * Create a user with realistic data for performance testing. + * + * Includes 3 accounts, 30 transactions, 15 balances, 5 categories, + * 3 labels, and 1 budget with a current period. + */ +function performanceSeedUser(): App\Models\User +{ + $user = App\Models\User::factory()->onboarded()->create(); + + $categories = App\Models\Category::factory(5)->create(['user_id' => $user->id]); + App\Models\Label::factory(3)->create(['user_id' => $user->id]); + + $accounts = App\Models\Account::factory(3)->create(['user_id' => $user->id]); + + foreach ($accounts as $index => $account) { + App\Models\Transaction::factory(10)->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $categories->random()->id, + ]); + + for ($i = 0; $i < 5; $i++) { + App\Models\AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subDays(($index * 5) + $i + 1)->toDateString(), + ]); + } + } + + $budget = App\Models\Budget::factory()->monthly()->create(['user_id' => $user->id]); + App\Models\BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->startOfMonth(), + 'end_date' => now()->endOfMonth(), + ]); + + return $user; +} + +/** + * Count the number of database queries executed by the given callback. + * + * @return array{count: int, queries: list} + */ +function countQueries(\Closure $callback): array +{ + $queryLog = []; + + Illuminate\Support\Facades\DB::listen(function ($query) use (&$queryLog) { + $queryLog[] = $query->sql; + }); + + $callback(); + + return ['count' => count($queryLog), 'queries' => $queryLog]; +} + +/** + * Assert that the callback executes at most $max database queries. + * + * On failure, dumps all executed queries for easy debugging. + */ +function assertMaxQueries(int $max, \Closure $callback, string $context = ''): void +{ + $result = countQueries($callback); + + if ($result['count'] > $max) { + $message = "{$context}: Expected at most {$max} queries, but {$result['count']} were executed.\n\nQueries:\n"; + foreach ($result['queries'] as $i => $sql) { + $message .= sprintf(" %d. %s\n", $i + 1, $sql); + } + test()->fail($message); + } + + expect($result['count'])->toBeLessThanOrEqual($max); +} + function createCategoryViaUI($page, string $name, string $color = 'green', string $type = 'Expense'): void { $page->click('Create Category')