# Backend Journey Tests — Architecture & Author's Guide

This is the operational doc for the backend-journey test suite that replaces
manual end-to-end click-throughs. It pairs with `backend-journey-plan.md`
(strategic / why) and `e2e-testing-proposal.md` (the larger QA strategy).

If you (or an AI agent) are about to add a new journey test, read this whole
file first. It exists so the next journey takes 30 minutes, not 3 hours.

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│  tests/Feature/Journeys/                                         │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  AnalysisRunJourneyTest          (real engines, no fakes) │  │
│  │  DraftGenerationJourneyTest      (uses OpenAiSimulator)   │  │
│  │  DraftPublishingJourneyTest      (uses WpApiClientSim.)   │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              ↓ uses                              │
│  tests/Simulators/                                               │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Contracts/Simulator.php         (interface)              │  │
│  │  OpenAiSimulator.php             (Http::fake)             │  │
│  │  WpApiClientSimulator.php        (container instance)     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              ↓ exercises                         │
│  app/                                                            │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Real controllers, jobs, services, models — full stack    │  │
│  │  with NO production code mocked or stubbed                │  │
│  └──────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
```

The test makes a real HTTP request to a real controller, which dispatches
a real job, which runs a real service, which calls a real client. The
client's HTTP layer is the only place the simulator intervenes. Every
piece of business logic between the request and the DB outcome is exercised.

## What is — and is NOT — a journey test

| Test type | Purpose | Mocks | Where |
|---|---|---|---|
| Unit (`tests/Unit/`) | One class in isolation | Liberally | Existing |
| Feature (`tests/Feature/`) | Controller + a few collaborators | Targeted | Existing |
| **Journey (`tests/Feature/Journeys/`)** | **Full request → DB outcome chain** | **Only external services (via simulators)** | **NEW** |
| Browser E2E (`tests/e2e/`) | Inertia hydration, JS-driven flows | None (real env) | Existing — Playwright |

A journey test is **not** "a feature test with more steps." Two distinguishing
properties:

1. **No production code is mocked.** Real `AnalysisEngine`, real
   `RecommendationEngine`, real `DraftGeneratorService`, real `WpApiClient`.
   If you find yourself reaching for `Mockery::mock(SomeService::class)`
   on an internal service, that's a feature test, not a journey test.

2. **External services use simulators, not Mockery.** The simulator returns
   shape-correct responses at the HTTP/client boundary. This exercises
   real serialization, real error parsing, real retry logic.

If both conditions hold, it's a journey test and belongs in `Journeys/`.

## Simulator catalog

### OpenAiSimulator

- **Boundary:** `Http::fake()` against `*chat/completions*` URL pattern
- **Real code exercised:** OpenaiService, RetryableHttpRequest,
  CircuitBreakerService, response parser, all error classification
- **Failure modes:** `expectRateLimit()`, `expectQuotaExceeded()`,
  `expectInvalidApiKey()`, `expectServerError()`, `expectMalformedResponse()`
- **Assertions:** `assertCalled(N)`, `assertNotCalled()`, `assertScriptExhausted()`
- **Used by:** `DraftGenerationJourneyTest` (and any future journey that
  triggers an AI call)

### WpApiClientSimulator

- **Boundary:** Subclass of `WpApiClient` bound to the service container
  via `app()->instance(WpApiClient::class, $sim)`. The job code
  (`PublishDraftToWpJob`) explicitly checks the container before
  constructing a real client, making this binding the canonical injection point.
- **Real code exercised:** PublishDraftToWpJob, WpPublishService,
  WpPublishLog state machine, HMAC signature plumbing (the parent's
  `publishContent` is overridden, but the constructor still runs against
  a real `WpConnection`)
- **What's NOT exercised:** real HTTP transport, real HMAC verification,
  the WP plugin REST routes. Those belong in `WpApiClientTest` and
  `WpApiClientCircuitBreakerTest`.
- **Failure modes:** `expectPublishConflict()` (200-OK conflict flag),
  `expectPublishFailure(int $status)` (transport-level via WpApiException),
  `expectPublishHttpConflict()` (409 status code)
- **Assertions:** `assertPublishCalled(N)`, `assertLastPublishContent($s)`,
  `assertLastPublishType($s)`
- **Used by:** `DraftPublishingJourneyTest`

### Not yet built — deferred for future journeys

| Simulator | Triggered by journey | Status |
|---|---|---|
| `GscApiSimulator` | GSC Sync | Deferred (analysis reads pre-synced DB data; not needed for J1) |
| `DataForSeoSimulator` | SERP / Content Score | Deferred (not in the top-3 journeys) |
| `StripeWebhookSimulator` | Subscription state changes | Deferred (Cashier helpers cover most cases) |

## Anti-patterns — banned in journey tests

These will sneak past PR review unless you watch for them. Each makes the
journey test perform but stop catching real bugs.

### ❌ Mocking a production service

```php
// WRONG — this is now a feature test, not a journey test
$mock = Mockery::mock(DraftGeneratorService::class);
$mock->shouldReceive('generateSingle')->andReturn(new AiDraft);
$this->app->instance(DraftGeneratorService::class, $mock);
```

If you need to fake out a service, the test belongs in `tests/Feature/`,
not `tests/Feature/Journeys/`. The journey test exists precisely to catch
bugs in that service.

### ❌ Asserting on `Queue::assertPushed` without running the job

```php
// WRONG — controller layer only; the job's behavior is untested
Queue::fake();
$this->post(route('analysis.store', $site), ...);
Queue::assertPushed(RunAnalysisJob::class);  // and stop here
```

A journey test must drive the job through completion (use
`Bus::dispatchSync($job)` or omit `Queue::fake()` so the sync queue runs
it inline). Otherwise it doesn't test the journey, it tests "the
controller dispatches a job."

### ❌ Asserting only on session flash messages

```php
// WRONG — passes if the controller reached redirect() on ANY path
$response->assertSessionHas('success');
```

Pair every controller-level assertion with a DB-state assertion that
proves the journey actually completed.

### ❌ Catching unexpected exceptions silently

```php
// WRONG — masks real bugs the journey would otherwise surface
try {
    $this->post(...);
} catch (\Throwable $e) {
    // ignore
}
```

Let exceptions propagate. Use `$this->expectException(SpecificException::class)`
when you genuinely want to assert a specific failure mode.

### ❌ Configuring the simulator INSIDE the assertion

```php
// WRONG — the script only runs for the request that follows it
$this->openai->expectSuccess('foo');
$this->post(...);
$this->openai->expectSuccess('bar');  // dead code; the request is over
$response->assertOk();
```

Queue all simulator expectations BEFORE the request that triggers the
journey. Read the journey top-to-bottom: setup → expect → act → assert.

### ❌ Asserting on too-broad text patterns

```php
// WRONG — matches almost any HTML
$this->assertStringContainsString('content', $aiDraft->draft_content);
```

Assert on a distinctive substring you actually placed in the simulator
response. The whole point of strong assertions is that mutating the
production code in obvious ways causes the test to fail.

## How to add a new journey

For each new journey, you'll touch ~3 files. Follow the recipe.

### Step 1 — Identify the call graph

Walk the journey on paper:
- HTTP method + route name + controller method
- Validations (FormRequest)
- Authorization (Policy)
- Service-layer collaborators
- Jobs dispatched (and what each one does)
- DB rows created/updated
- External service calls (which need simulators)

If any external service in the call graph doesn't have a simulator yet,
add it first (Step 2). Otherwise skip to Step 3.

### Step 2 — Add a simulator (only if needed)

Template: copy `OpenAiSimulator.php` (HTTP-layer simulator) or
`WpApiClientSimulator.php` (container-layer simulator) and adapt:

1. Choose the boundary:
   - Service uses `Http::*` directly → HTTP-layer simulator with `Http::fake`
   - Service is resolved from container as a class → container-layer simulator
2. Implement `install()`, `respond()` / overridden methods, the failure-mode helpers, and the `Simulator` interface assertions
3. Document failure modes in the class docblock
4. Add a row to the catalog above

### Step 3 — Write the journey test

Template:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Journeys;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Simulators\WhicheverSimulator;
use Tests\TestCase;

class FoobarJourneyTest extends TestCase
{
    use RefreshDatabase;

    private WhicheverSimulator $sim;
    // ... other shared state

    protected function setUp(): void
    {
        parent::setUp();
        // Build the seeded state via factories.
        // Install all simulators the journey will hit.
        $this->sim = WhicheverSimulator::install();
    }

    public function test_happy_path_full_journey_produces_correct_db_state(): void
    {
        $this->sim->expectSuccess(/* shape-correct response */);

        $response = $this->actingAs($this->user)->post(route('the.route', ...));

        $response->assertRedirect();
        $response->assertSessionHas('success');

        // Strong DB-state assertions on what the journey persisted
        // ...

        $this->sim->assertCalled(1);
    }

    // One test per failure mode the journey should handle gracefully
    public function test_<failure-mode>_produces_user_error_and_no_side_effects(): void
    {
        $this->sim->expectFailure();

        $response = $this->actingAs($this->user)->post(route('the.route', ...));

        $response->assertRedirect();
        $response->assertSessionHas('error');

        // Assert NO side effects landed in DB
        $this->assertSame(0, RelevantModel::count());
    }

    // Authorization / cross-tenant
    public function test_cross_tenant_user_is_blocked_before_external_call(): void
    {
        $this->sim->expectSuccess('SHOULD NOT BE RETURNED');

        $response = $this->actingAs($otherUser)->post(/* ... */);

        $this->assertNotEquals(200, $response->getStatusCode());
        $this->sim->assertNotCalled();
    }

    // Validation: malformed inputs rejected before the service is invoked
    public function test_invalid_input_is_rejected_at_validation(): void
    {
        $response = $this->actingAs($this->user)->post(route('the.route', ...), [/* bad input */]);

        $this->assertContains($response->getStatusCode(), [302, 422]);
        $this->sim->assertNotCalled();
    }
}
```

### Step 4 — Run only the new journey first

```bash
./vendor/bin/pest tests/Feature/Journeys/FoobarJourneyTest.php --stop-on-failure
```

Iterate until green. Only then run the full journey suite to confirm no
cross-test interference.

### Step 5 — Add the journey to the suite

The `tests/Feature/` directory is auto-discovered by `phpunit.xml`. Just
adding the file is enough; CI will pick it up on the next push.

## Performance budget

A journey test should complete in ≤ 1 second on SQLite in-memory. If
yours doesn't, common culprits:

- **Real OpenAI/WP API calls leaking through.** Verify with
  `Http::preventStrayRequests()` in `setUp()`. Any leak should fail
  with a clear "stray request" error.
- **Heavy seeding.** A journey test should seed only what the journey
  exercises. If you're seeding 500 GSC metric rows, the test is doing
  unit-test work; refactor the seed.
- **Real queue worker.** The `sync` driver is set in `phpunit.xml`. If
  you've overridden it, you're not running synchronously.

## CI integration

The journey suite runs as part of the default Pest run. No CI changes
were needed — `phpunit.xml` already includes `tests/Feature/`. You can
target only journeys with:

```bash
./vendor/bin/pest tests/Feature/Journeys/ --parallel --processes=4
```

The full journey suite should complete in well under 10 seconds. If it
doesn't, the budget rule above is being violated.

## Honest caveats

### What journey tests don't catch

- **Frontend regressions.** Inertia hydration crashes, React component
  bugs, JS-driven UX. Those live in the Playwright suite. Recommended
  posture going forward: trim Playwright to ~5 critical browser-only
  flows, since backend journeys subsume the rest.

- **Real external API drift.** If OpenAI changes the response shape, the
  simulator (which agrees with our parser) keeps passing while production
  breaks. The drift canary (M6 in `implementation-milestones.md`) is the
  defense; it's deferred work.

- **Performance regressions in production.** Journey tests run against
  SQLite in-memory; production is MySQL. Some N+1 bugs show up only
  under MySQL planner behavior. Lazy-loading-disabled mode (already on
  in dev/test per CLAUDE.md) catches the worst class of these, but not
  all.

### When to suspect a journey test

If a journey test passes for a long time and then "suddenly" misses a
bug that bit production:

1. Did the simulator drift from the real API? Run the drift canary
   manually against a real sandbox account.
2. Was the assertion too loose? Apply the discriminating-mutation test:
   manually break the production code in the relevant way and verify
   the journey fails. If it doesn't, the assertion is performative.
3. Was the journey scope too narrow? Maybe the bug lives in a path the
   journey doesn't exercise (e.g., draft generation succeeded but the
   AiJob lifecycle transition didn't happen). Add an assertion.

## Future journeys (deferred)

The 8-journey ranked list from `backend-journey-plan.md` — the three
shipped, the five remaining:

| Status | Journey | Trigger |
|---|---|---|
| ✅ done | Analysis Run | `tests/Feature/Journeys/AnalysisRunJourneyTest.php` |
| ✅ done | Draft Generation | `tests/Feature/Journeys/DraftGenerationJourneyTest.php` |
| ✅ done | Draft Publishing | `tests/Feature/Journeys/DraftPublishingJourneyTest.php` |
| pending | GSC Sync | needs `GscApiSimulator` |
| pending | WP Connect & Initial Sync | uses existing `WpApiClientSimulator` + handshake |
| pending | ROI Tracking | uses scenario engine; no new simulator |
| pending | Limit Enforcement | uses Cashier helpers + existing simulators |
| pending | Onboarding First Analysis | uses all the above; longest journey |

Each of the pending journeys follows the recipe in "How to add a new
journey." Estimated effort once the pattern is internalized: 1–2 hours
per journey, plus 2–4 hours for any new simulator.
