# Test Suite

## Running Tests

```bash
# PHP — use Pest directly (`php artisan test --parallel` can segfault)
./vendor/bin/pest --parallel --processes=4            # Full suite
./vendor/bin/pest --dirty --parallel                  # Only files with uncommitted changes (TDD loop)
./vendor/bin/pest tests/Feature/SomeTest.php          # Single file
./vendor/bin/pest --filter="test_name_substring"      # Filter by name
./vendor/bin/pest tests/Contracts/                    # Contract tests only

# Frontend
npx vitest run                                        # Full suite
npx vitest run resources/js/Pages/SomePage.test.tsx   # Single file
npx vitest run --changed                              # Only changed files
```

**During TDD red-green iteration, NEVER run the full suite.** Use `--dirty`, `--filter`, or target a single file. Full suite runs once at the end via `/v-pre-flight`.

## Test Organization

```
tests/
├── Contracts/   Safety-rail tests preventing silent regressions (Inertia page existence, props consistency, feature-flag contracts)
├── Feature/     Integration — HTTP requests through full stack, RefreshDatabase, SQLite in-memory
├── Unit/        Isolated — no DB, no HTTP, mocked dependencies (mirrors app/ structure)
└── e2e/         Playwright E2E — auth flows, critical paths
```

To list contract tests: `ls tests/Contracts/`. To list test categories: `ls tests/Feature/ tests/Unit/`.

## Framework & Conventions

- **Pest** (not raw PHPUnit) — uses `it()`, `test()`, `describe()` syntax
- **SQLite in-memory** for speed (MySQL in CI)
- **RefreshDatabase** trait on all feature tests
- **Factories** for all models — always use factories, never manual `create()`
- **Lazy loading disabled** — must eager load relationships before accessing

## Pest.php Global Helpers

`tests/Pest.php` defines helpers available in all test files:

**Billing:**
- `ensureCashierTablesExist()` — creates Cashier schema on-the-fly (call in `beforeEach()`)
- `createSubscription($user, $overrides)` — creates active pro subscription without Stripe
- `createTeamSubscription($user, $seats, $overrides)` — team-tier subscription
- `createEnterpriseSubscription($user, $seats, $overrides)` — enterprise-tier subscription
- `registerBillingRoutes()` — re-registers billing routes (required — see route gotcha below)
- `createStripeWebhookPayload($eventType, $objectData, $eventId)` — builds Stripe event payload

**Admin:**
- `registerAdminRoutes()` — re-registers admin routes (idempotent, checks `Route::has()`)
- `adminGet($uri, $params, $admin)`, `adminPatch()`, `adminPost()`, `adminDelete()` — request + route registration

**Feature flags:**
- `setFeatureFlagOverride($flag, $enabled, $userId)` — sets flag + clears cache
- `clearFeatureFlagOverrides()` — cleanup

**Other:**
- `impersonationSession($adminId, $name)` — encrypted session array for impersonation tests
- `mockValidationFail()` — returns `[$fail, $wasCalled, $getMessage]` for custom rule testing

## Contract Tests (tests/Contracts/)

Safety-rail tests that prevent silent regressions. Common contracts:

| Contract | Enforces |
|----------|----------|
| `InertiaPageExistenceTest` | Every `Inertia::render('Page/Name')` has a matching `.tsx` file |
| `InertiaPropsContractTest` | Multiple controllers rendering same page emit identical top-level prop keys |
| `FeatureFlagContractTest` | Route-dependent flags can't be DB-overridden; `admin` is protected |
| `*PageContractTest` | Per-page prop contracts (Opportunity Map, Device, Geographic, Site Analysis Settings, etc.) |

To list current contracts: `ls tests/Contracts/`.

## Critical Test Patterns

### Feature-Gated Route Registration (Critical Gotcha)

Routes behind `if (config('features.billing.enabled'))` in `web.php` are evaluated **at boot time**, before any `config()` call in `beforeEach()`. Setting `config(['features.billing.enabled' => true])` does NOT register the routes.

```php
// WRONG — routes don't exist even though config is set
config(['features.billing.enabled' => true]);
$this->get('/billing')->assertOk();  // 404!

// CORRECT — use the Pest.php helper
registerBillingRoutes();
$this->actingAs($user)->get('/billing')->assertOk();

// For admin routes:
registerAdminRoutes();
// Or use convenience wrappers:
adminGet('/admin/users');
```

### Always Eager Load Before Service Calls

```php
// WRONG — triggers lazy loading violation
$user = User::factory()->create();
$user->aiKey;  // throws!

// CORRECT
$user = User::factory()->create();
$user->loadMissing('aiKey');
$user->aiKey;  // works
```

### Fake Notifications BEFORE Creating Models

```php
// WRONG — notification fires during factory create
$user = User::factory()->create();
Notification::fake();

// CORRECT
Notification::fake();
$user = User::factory()->create();
```

### AI Test Retry Sleep

```php
// Prevent slow retry delays in AI service tests
config(['ai.openai.retry_sleep_ms' => [0, 0, 0]]);
```

### Queue is Sync — Use Queue::fake() to PREVENT Execution

```php
// phpunit.xml sets QUEUE_CONNECTION=sync — jobs run inline immediately.
// Jobs that dispatch inside service calls execute synchronously in tests.

// WRONG — assumes job didn't run (it did, synchronously)
$service->doSomething();

// CORRECT — use Queue::fake() to prevent execution and assert dispatch
Queue::fake();
$service->doSomething();
Queue::assertPushed(SomeJob::class);

// CORRECT — jobs run synchronously, test their side effects directly
$service->doSomething();
$this->assertDatabaseHas('results', [...]);  // job effects are immediate
```

### Feature Flags in phpunit.xml

```php
// phpunit.xml enables ALL features EXCEPT:
//   - FEATURE_NOTIFICATIONS (defaults false)
//   - FEATURE_API_DOCS (defaults false)
// All others (FEATURE_BILLING, FEATURE_ADMIN, FEATURE_SOCIAL_AUTH,
// FEATURE_TWO_FACTOR, FEATURE_WEBHOOKS, etc.) are enabled by default.
//
// But feature-gated ROUTES still need the register helpers — see above.
```

### Dashboard Redirects

```php
// Dashboard redirects to first site's onboarding or sites.create
// Don't test Inertia props on /dashboard — use /profile instead
$this->get('/dashboard')->assertRedirect();
$this->get('/profile')->assertInertia(...);
```

### SQLite Cascade Delete Workaround

```php
// SQLite doesn't cascade with ::where()->delete()
// Must delete leaf records explicitly
AiDraft::where('ai_job_id', $job->id)->delete();
AiJob::where('site_id', $site->id)->delete();
Recommendation::where('analysis_run_id', $run->id)->delete();
Finding::where('analysis_run_id', $run->id)->delete();
$run->delete();
$site->delete();
```

### Inertia Component Testing (When Pages Don't Exist Yet)

```php
// Pass false as 2nd arg to skip page file existence check
$response->assertInertia(fn ($page) => $page->component('PageName', false));
```

## TDD Mandate

| Code Type | Approach |
|-----------|----------|
| Services | Test FIRST → fail → implement → pass |
| Controllers | Test FIRST → fail → implement → pass |
| Jobs | Test FIRST → fail → implement → pass |
| UI Components | Implement first → test after |
| Models/Factories | Create together, no separate test needed |

## Writing New Tests

### Feature Test (controller/integration)

```php
// tests/Feature/Controllers/NewControllerTest.php
it('lists items for authenticated user', function () {
    $user = User::factory()->create();
    $site = Site::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user)
        ->get("/sites/{$site->id}/items")
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->component('Items/Index')
            ->has('items')
        );
});

it('denies access to other users sites', function () {
    $owner = User::factory()->create();
    $other = User::factory()->create();
    $site = Site::factory()->create(['user_id' => $owner->id]);

    $this->actingAs($other)
        ->get("/sites/{$site->id}/items")
        ->assertForbidden();
});
```

### Unit Test (service with mocked dependencies)

```php
// tests/Unit/Services/NewServiceTest.php
it('processes data correctly', function () {
    $dependency = Mockery::mock(SomeDependency::class);
    $dependency->shouldReceive('fetch')->once()->andReturn(['data']);

    $service = new NewService($dependency);
    $result = $service->process();

    expect($result)->toBeArray()->toHaveCount(1);
});
```

### Frontend Test

```tsx
// resources/js/Pages/Items/Index.test.tsx
import { render, screen } from "@testing-library/react";
import Index from "./Index";

const mockProps = { items: [{ id: 1, name: "Test" }] };

describe("Items/Index", () => {
  it("renders item list", () => {
    render(<Index {...mockProps} />);
    expect(screen.getByText("Test")).toBeInTheDocument();
  });
});
```

## Test Quality Rules

- Never delete or weaken assertions to make tests pass — fix the code
- Assertions must verify **user-visible behavior**, not implementation details
- Test comments must match what assertions actually verify
- No `TODO`/`FIXME`/`HACK` in test code
- Every mutation endpoint: test validation, authorization, happy path, and error paths
- Site-scoped routes: test that other users cannot access (403)

## Git Hooks

| Hook | Runs | Timing |
|------|------|--------|
| pre-commit | `lint-staged` — eslint fix + pint on **staged files only** | Fast (~2-5s) |
| pre-push | Full `npm run lint` + `npx tsc --noEmit` + `npm test --run` (vitest) | Slow (~30-60s) |

The pre-push hook runs the full vitest suite. Expect a ~30-60s delay on every push. Broken tests block push.

## Feature-Gated Route Helpers

Only billing routes (prefixed `if (config('features.billing.enabled'))` in `routes/web.php`) and admin routes (prefixed `if (config('features.admin.enabled'))`) use `registerBillingRoutes()` / `registerAdminRoutes()` helpers. These helpers exist because billing/admin routes use **boot-time `if (config(...))` guards** in the route file — if the feature is disabled at boot, the route is never registered.

TwoFactor, Webhooks, and Notifications use the runtime `feature-gate` middleware instead (routes always register, middleware enforces access at request time). These features do NOT need register helpers — they work with standard `config([...])` overrides in test `setUp()`.

## PHP JSON Serialization of Floats (Important)

PHP's `json_encode()` converts whole-number floats to integers: `json_encode(50.0)` → `50`. Inertia serializes props via JSON, so any float value that is a whole number (e.g., `0.0`, `50.0`, `100.0`) will arrive in the test as an integer.

**Rule:** When asserting float props that may be whole numbers, use the integer form:
```php
// WRONG — fails when value is 50.0 (JSON sends integer 50)
->where('clicks_delta_pct', 50.0)

// CORRECT
->where('clicks_delta_pct', 50)

// Or prevent the issue at the source by ensuring factory produces decimal:
RecommendationRoiSnapshot::factory()->create(['clicks_delta_pct' => 50.25])
```

For fractional floats (e.g., `50.5`, `33.33`), the normal float assertion works fine.

## Additional Test Gotchas (moved from root CLAUDE.md)

### AssertableInertia is Not an Array

`$page['key']` throws "Cannot use AssertableInertia as array". Always read props via `$page->toArray()['props']['key']` inside `assertInertia` callbacks.

### Jobs That `new` Their Dependencies

If a job constructs a service with `new Foo(...)` the container can't inject a test mock. Pattern: check container first — `app()->has(Foo::class) ? app(Foo::class) : new Foo(...)` — so tests can call `app()->instance(Foo::class, $mock)` without touching the job.

### Mockery + Eloquent Object Identity

`$mock->with($model)` uses `===` identity — fails when the job/service receives the model via a fresh DB load (different PHP object). Use `Mockery::on(fn($s) => $s->id === $model->id)` for all Eloquent model arguments in mock expectations.

### Factory FK Drift in Site-Scoped Tests

Factories for `TrafficAlert`, `AiDraft`, etc. auto-create their own `AnalysisRun`/`Site`. When testing site-scoped filtering, always pass `site_id` (and `analysis_run_id`) explicitly pointing to the test's site — otherwise alerts appear on a different site and the index returns 0 records.

### Paginated Inertia Props

When a controller paginates a collection, test with `has('prop.data', N)` (not `has('prop', N)`). Count/meta lives at `prop.meta.total`, not `pagination.total`. Nested show() data may be under a parent key — check the controller before writing assertions.

### Circuit Breaker Open → Log Mock Count

When a circuit breaker is open the job exits early — the "completed" log line is never written. Mock `Log::shouldReceive('info')->once()` (starting only), not `->twice()` (starting + completed).

### `Queue::fake()` Kills E2E Side Effects

In tests that verify audit logs, DB records, or notifications created *inside* a job, don't fake the queue — the job runs synchronously (`QUEUE_CONNECTION=sync`). Instead, mock only the external API dependency (e.g., `app()->instance(OpenAiService::class, $mock)`) and let the job execute.

### `Model::create()` in Tests

Always use factories — `ModelName::factory()->create()`. Never use raw `Model::create()` arrays.
