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')