> ## Documentation Index
> Fetch the complete documentation index at: https://docs.highailabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tier C — Staging Integration

> How the staging-integration tier drives the real deployed backend with isolated, really-logged-in Clerk identities — plus its production guard, tags, and cost controls.

## What Tier C is for

Tier C runs Vitest suites against the **real staging backend**: the shared staging
Convex deployment, the Clerk development instance, the production Hono API, and —
for pipeline cases — Trigger.dev.

Tiers A and B never touch a deployment. Tier C exists for exactly the things they
cannot see:

* scheduled side effects on a real deployment
* real argument validators, as deployed
* real Clerk JWT verification, end to end
* outbound `fetch` from Convex actions
* webhook round-trips and pipeline completion

<Warning>
  **Limitation to state in every suite header:** `ConvexHttpClient` has no
  subscriptions, so every convergence check is a **poll**. Tier C proves
  *eventual* correctness, not the reactive delivery `useQuery` gives the app.
</Warning>

## Running it

```bash theme={null}
cd apps/mobile

pnpm test:integration:smoke   # harness self-test — under 30 s, no AI spend
pnpm test:integration:fast    # everything not tagged @slow
pnpm test:integration         # full suite (30–60 min)
pnpm test:integration:orders  # one area (also: stash, collection, notebooks, gmail,
                              # achievements, dispensaries, users, subscriptions)
```

Always run the smoke suite before a lane. It proves Clerk → Convex JWT
verification, the fixture surface, the Hono API leg, the admin runner, and the
production guard in under 30 seconds. A lane failure *after* a green smoke is a
product signal rather than a harness signal.

A per-area script whose suite file does not exist yet exits 1 with
`No test files found` and prints the filter it used. That is deliberate: once a
suite exists, a silently-empty run is exactly the failure mode a weekly job must
never hide.

## The isolated-user pattern

Every suite creates a **brand-new Clerk user per test and deletes it afterwards**.
Each identity is a real Clerk account that really signs in — the suite mints real
session JWTs from it, so Convex and the Hono API verify the same tokens the app
would present. This is the isolation mechanism, and it also gives every run a
fresh API rate-limit bucket.

```ts theme={null}
import { withTestUser } from '../helpers/identity';

await withTestUser(async (user) => {
  // user.client        -> ConvexHttpClient authenticated as this user
  // user.session       -> clerkUserId, email, sessionId, convexJwt, apiSessionToken
  // user.refreshAuth() -> re-mint both tokens mid-test
}, { persona: 'orders-projection' });
```

`cleanup()` clears the test user's rows, asserts that every `remaining*` counter
is zero — a leaked row fails the test rather than silently accumulating on
staging — then deletes the Clerk user. It runs **always**, even when the wipe
failed, so a failing test cannot leak identities.

<Warning>
  **Never reuse the shared `+clerk_test` personas** (`power`, `testuser`,
  `onboarding`, `lapsed`, `ranked`, …). They belong to Revyl, whose auth bypass
  wipes their stash on every launch — a Tier C test seeded on one can be cleared
  mid-run.
</Warning>

The `+clerk_test` local-part suffix is **mandatory**: every fixture mutation is
gated on it server-side and throws without it.

## The production guard

Three independent layers, all of which must agree before a single row is written:

<Steps>
  <Step title="assertStagingConvexUrl() in the harness">
    Enforced by the suite's `beforeAll`. It rejects the production deployment with
    a distinct message, then rejects every other host. It is a pure function and
    is unit-tested in the smoke suite with no network.
  </Step>

  <Step title="The pnpm scripts hardcode the staging URL">
    There is no secret and no env file that can be repointed at production — you
    would have to edit `package.json`.
  </Step>

  <Step title="assertNotProdDeployment() server-side">
    Every fixture mutation calls it first, so even a hand-rolled client cannot run
    fixture writes against production.
  </Step>
</Steps>

<Warning>
  Never turn `TIER_C_CONVEX_URL` into a repository secret. A secret would be a
  fourth, *repointable* layer and would defeat the other three.
</Warning>

## Required environment (names only)

| Variable                 | Required | Purpose                                                                                              |
| ------------------------ | :------: | ---------------------------------------------------------------------------------------------------- |
| `TIER_C_CONVEX_URL`      |    yes   | Must be the staging deployment. Supplied inline by the pnpm scripts and hardcoded in the CI workflow |
| `CLERK_SECRET_KEY`       |    yes   | Mints the isolated test user and its session tokens                                                  |
| `CONVEX_DEPLOY_KEY`      |    no    | Switches the admin runner to deploy-key mode                                                         |
| `TRIGGER_SECRET_KEY`     |    no    | Polls and cancels Trigger.dev runs; without it, `@trigger` cases skip rather than hang               |
| `INTERNAL_API_SECRET`    |    no    | Reads the entitlement projection directly; without it those cases skip loudly                        |
| `GMAIL_DIAGNOSTIC_EMAIL` |    no    | Names the Clerk identity that owns the Google OAuth grant. Gmail suite only                          |
| `TIER_C_API_URL`         |    no    | Defaults to the production Hono API                                                                  |
| `TIER_C_PAID`            |    no    | Opt-in gate for cases that cost real money per run                                                   |

When a required variable is missing, suites **skip** rather than fail, and the
harness names exactly what is absent. Do not add `TIER_C_CONVEX_URL` to a
committed `.env` file — keeping it out is what makes running Tier C a deliberate
act.

## The admin runner

Some assertions need Convex `internal*` functions, which have no public
equivalent. `createAdminRunner()` has three modes:

| Mode          | When                             | Notes                                                                                                                                                   |
| ------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deploy-key`  | a deploy key is set              | Uses an internal client API absent from Convex's published types; the structural cast is isolated to one file so a Convex bump breaks exactly one place |
| `cli`         | the repo's Convex CLI is present | Shells out through an argument array (no shell interpolation), using the developer's CLI login. **This is the mode that runs locally today**            |
| `unavailable` | neither                          | Every call throws with instructions; gate admin-only tests with `it.skipIf(!adminRunner.available)`                                                     |

Codegen and typecheck are disabled on every CLI call, so Tier C never rewrites
`convex/_generated` or your working tree.

## Tags

Tags live in describe/test titles because Vitest `-t` matches the concatenated
title.

| Tag               | Meaning                                                                   |
| ----------------- | ------------------------------------------------------------------------- |
| `@smoke`          | Under 30 s, no AI, no Trigger. Safe on every invocation                   |
| `@slow`           | Over 60 s expected (Trigger or AI in the loop). Excluded by the fast lane |
| `@ai`             | Spends AI Gateway credits                                                 |
| `@trigger`        | Creates a Trigger.dev run in the **prod** environment                     |
| `@shared-account` | Cannot use an isolated user (Gmail). Must be serialized                   |
| `@P0`–`@P3`       | Priority, matching the audit-report contract                              |

## Cost and blast radius

<Warning>
  Tier C spends real money and writes to a shared deployment. Budget before
  running.
</Warning>

* **Trigger.dev runs land in the prod environment**, sharing queues, concurrency,
  and cost with real user work. An abandoned run keeps executing and keeps billing
  after Vitest has moved on — which is why the harness cancels a run it abandons
  on timeout, and why the CI concurrency group never cancels a run in flight.
* **The notebook pipeline is a deterministic builder** with no model call in its
  path, so that suite's AI spend is effectively nil (compute is a fraction of a
  cent per full run). A case that forces genuine strain research is the expensive
  exception.
* **Gmail classification calls a real model** for candidates that miss the
  fast path. Cost scales with the scan window, so the window stays pinned narrow
  (\~30 days). If a Gmail case fails on volume, do not widen the window to "get
  more signal".
* **Dispensary menu scans are paid per cold scan** (5–6 minutes each) and run only
  under the paid opt-in.
* **The Hono API leg hits the deployment real users hit**, at production rate
  limits. Keep API assertions read-only and user-scoped.
* **`retry: 0` is deliberate.** Never raise it to paper over flakiness — that pays
  twice for the same signal. Fix the flake or quarantine it with a tracked reason.
* **Staging is shared** by every non-production build plus a daily cron. Never
  assert on a global count, a "latest N" listing, or a cross-user aggregate.

## Gmail: the one shared-account exception

The Gmail-connected identity is a **specific, pre-existing** Clerk user with a
linked Google account; its OAuth grant lives in Clerk, not in any env file. A
freshly created test user has no Google account and cannot scan.

So the Gmail suite:

* runs against the identity named by `GMAIL_DIAGNOSTIC_EMAIL`, **not** an isolated
  user;
* is tagged `@shared-account` and must be **serialized** — one Gmail Tier C case at
  a time, and never while the Revyl order-import workflow is running, since both
  drive the same mailbox and the same per-user queue;
* must clean up after itself explicitly, because the isolated-user teardown does
  not apply;
* must pin a narrow scan window and assert on candidate **shape**, not
  exhaustiveness.

## Writing a new suite

```ts theme={null}
/**
 * @<area> — staging integration.
 *
 * Limitation: ConvexHttpClient has no subscriptions; convergence is polled.
 */
import { describe, expect, it } from 'vitest';

import { missingEnv, tierCConfigured } from '../env';
import { seedLaunchSmokeOrder } from '../helpers/fixtures';
import { withTestUser } from '../helpers/identity';
import { waitFor } from '../helpers/wait';

if (!tierCConfigured) {
  console.info(`[tier-c] <area> skipped — missing env: ${missingEnv.join(', ')}`);
}

describe.skipIf(!tierCConfigured)('@<area> <Area> — staging integration', () => {
  it('@P0 projects an order into the stash', async () => {
    await withTestUser(async (user) => {
      await seedLaunchSmokeOrder(user);

      const stash = await waitFor(
        () => user.client.query(api.stash.getStash, {}),
        rows => rows.length > 0,
        { label: 'order to project into stash', timeoutMs: 60_000 },
      );
      expect(stash.length).toBeGreaterThan(0);
    }, { persona: '<area>-happy-path' });
  });
});
```

Rules:

* Prefer the **public** fixtures — they run the real validators, the real identity
  guard, and the real scheduled side effects, which is the whole point. Reach for
  admin fixtures only when a case needs data no public mutation can create.
* Scope every assertion to the test user's own Clerk user id.
* Never string-compare a Trigger.dev run status; use the classifier helper, which
  fails closed.
* No `any` — parse boundaries as `unknown` and narrow.
* Log anything derived from real receipts or orders through the privacy-safe
  diagnostics helper. Tier C output lands in CI logs.

## Troubleshooting

| Symptom                                 | Cause                                                                                                            |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Every suite skips                       | A required variable is missing; the harness names it                                                             |
| "restricted to `+clerk_test` accounts"  | The email lost its `+clerk_test` local-part suffix                                                               |
| Auth error partway through a long test  | The Clerk session JWT expired — call `user.refreshAuth()` inside the wait loop                                   |
| A failure that looks like a product bug | Check schema drift first: a Convex dry-run deploy from `apps/mobile` shows what the repo would change on staging |
| "admin runner unavailable"              | Set a staging deploy key, or log the Convex CLI in so the CLI mode works                                         |

## Related

<CardGroup cols={2}>
  <Card title="Test Platform Overview" icon="vials" href="/planning/testing/feature-test-platform">
    The three tiers, what each proves, and how to add a test.
  </Card>

  <Card title="Subscription-State Testing" icon="lock" href="/planning/testing/subscription-state-testing">
    Free, active-Pro, and lapsed-Pro on a gate-enforcing deployment.
  </Card>
</CardGroup>
