<!-- Generated by scripts/build-ai-docs.ts from docs/testing.md. Do not edit. -->

# Testing with scenarios

`sk_test_` keys (and the admin in test mode) run on separate data with mocked carriers, so nothing
reaches a real carrier. By default the mocks behave. To exercise failure paths, ask them to
misbehave with a **scenario**. Scenarios work for every mock carrier: FedEx, USPS, UPS and MockEx.

**Live mode ignores scenarios completely.** A live key that sends the header or the token gets normal
behavior, and no error even for an unknown name.

## Two ways to ask

| Trigger | Where | Good for |
| --- | --- | --- |
| Header `VeloShip-Scenario: <name>` | Any request | Flipping a whole test run, or one call, without changing the payload |
| Token `__scenario:<name>` | In `to_address.street2` (or `street1`), or in `reference` when buying | Fixtures that travel with a shipment, so every later call (buy, void, tracking refresh, a retry from a queue) behaves the same |

If both are present the **header wins**, and the token is ignored for that request. An unknown name
in the header is a `422 SCENARIO.UNKNOWN` that lists the valid ones; an unknown token is ignored. When
a header scenario applies, the response echoes it in a `VeloShip-Scenario` header, and the
[request log](https://ship.vprev.dev/docs/md/api.md#get-v1request_logs) records it.

Add `@<carrier>` (`fedex`, `usps`, `ups`, `mockex`) to limit a scenario to one carrier, which is how to
test a quote where one carrier fails and the others answer: `VeloShip-Scenario: carrier_down@ups`.

```bash
# One call
curl -X POST https://ship.vprev.dev/v1/rates -H "Authorization: Bearer sk_test_…" \
  -H "VeloShip-Scenario: no_rates" -H "Content-Type: application/json" -d '{…}'

# Travels with the shipment: every call that touches this address misbehaves the same way
curl -X POST https://ship.vprev.dev/v1/shipments -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{"to_address":{"street1":"1 Market St","street2":"__scenario:purchase_timeout",…},…}'
```

Header scenarios skip the rate and address caches, so a cached answer can't hide them.

The TypeScript client sends the header for you: pass `scenario` in the client options, or in the options of one call.

## Scenarios

| Scenario | Happens at | What happens | What to expect |
| --- | --- | --- | --- |
| `address_invalid` | Address verification (`POST /v1/addresses` with `verify`, the residential check) | The address can't be matched | `verification.success: false`, warning `NOT_RESOLVED`; with `verify_strict`, `422 ADDRESS.VERIFY.FAILURE` |
| `address_suite_missing` | Address verification | A suite or apartment number is required | `success: false`, warning `SUITE_MISSING`, and a corrected address |
| `residential` | Address verification | The address is residential | `success: true`, `residential: true` |
| `commercial` | Address verification | The address is commercial | `success: true`, `residential: false` |
| `rates_slow` | Quoting | The carrier answers after `CARRIER_QUOTE_TIMEOUT_MS` (1.5 s by default) | With several carriers: the quote succeeds without it and lists `carrier_errors[]` with `CARRIER.TIMEOUT`. With only one: `504 CARRIER.TIMEOUT`. The call itself takes the budget plus 250 ms |
| `carrier_down` | Every carrier call | A 5xx from the carrier | `502 CARRIER.ERROR`. After five failures in a row the circuit breaker opens: `503 CARRIER.UNAVAILABLE` with `Retry-After` for 30 s. Requests without the scenario are unaffected by the open breaker |
| `carrier_rate_limited` | Every carrier call | The carrier answers 429 | `429 CARRIER.RATE_LIMITED` with `Retry-After: 60`. Retries with the same `Idempotency-Key` run again |
| `no_rates` | Quoting | The carrier has nothing for this shipment | `200` with `rates: []` (and nothing cached) |
| `purchase_timeout` | Buying | The carrier **buys the label**, but its answer never arrives | `502 SHIPMENT.PURCHASE_UNKNOWN`; the shipment is `purchase_unknown` and refuses a second buy (`409`). `POST /v1/shipments/:id/reconcile` finds the orphaned label, voids it and returns the shipment to `created`, and the next buy succeeds. Fires once per shipment. Only FedEx supports lookup by reference; for USPS, UPS and MockEx the shipment stays `purchase_unknown` with an explanatory `purchase_error`, as it would for real |
| `purchase_rejected` | Buying | The carrier refuses | `422 CARRIER.ERROR` with `details[0].code: SCENARIO.PURCHASE_REJECTED`; the shipment stays `created` and buyable |
| `customs_rejected` | Buying | The carrier refuses the customs declaration, as when an item description is too vague | `422 CARRIER.ERROR` with `details[0].code: SCENARIO.CUSTOMS_REJECTED`; the shipment stays `created`, so fix the declaration on a new shipment and buy that |
| `auth_rejected` | Testing a carrier account (`POST /v1/carrier_accounts/test`, `/:id/test`) | The carrier rejects the credentials | `200` with `status: "fail"`; the `auth` check fails and the others are `skipped` |
| `labels_not_enabled` | Testing a carrier account, and buying | The carrier hasn't enabled label creation | The test returns `status: "warn"` with a `labels` warning and hint. Buying fails with `422 CARRIER.LABELS_NOT_ENABLED` and flags the account's rates `purchasable: false` |
| `charge_mismatch` | Buying | Billed 25% above the quote | The purchase succeeds with `charged.amount` ≠ `selected_rate.rate` and `charge_mismatch: true` |
| `void_rejected` | Voiding | The carrier refuses to cancel, e.g. already picked up | `422 SHIPMENT.REFUND.REJECTED`. Put the token on the shipment at purchase, or send the header on the refund call |
| `delivered_fast` | Tracking | `pre_transit` at 0 s, `in_transit` 10 s, `out_for_delivery` 20 s, `delivered` 30 s | `status` follows the clock; `tracking_details` grows with it |
| `exception_then_delivered` | Tracking | `pre_transit`, `in_transit` 10 s, an `in_transit` event "Delivery exception: recipient not available" at 20 s, `out_for_delivery` 40 s, `delivered` 50 s | Exceptions show as `in_transit` with a `status_detail` |
| `return_to_sender` | Tracking | `pre_transit`, `in_transit` 10 s, delivery attempted 20 s, `return_to_sender` 30 s | Terminal status `return_to_sender` |
| `lost` | Tracking | `pre_transit`, `in_transit` at 10 s, then nothing, ever | Stuck in `in_transit`; useful for testing "no scan in N days" alerts |

Tracking scenarios need a tracking number to follow, so choose them when you buy (token, or header on
the buy call). Status advances when the tracker refreshes: `POST /v1/trackers/:id/refresh`, or the
15-minute cron. A `POST /v1/trackers` with the header and a number that isn't one of ours also starts
a timeline for it.

## Examples

```bash
# Quote with UPS down and FedEx answering: 200, FedEx rates, carrier_errors names UPS
curl -X POST …/v1/rates -H "VeloShip-Scenario: carrier_down@ups" …

# Lost purchase, then recovery
curl -X POST …/v1/shipments …   # to_address.street2 = "__scenario:purchase_timeout"
curl -X POST …/v1/shipments/shp_…/buy -d '{"service":"FEDEX_GROUND"}'   # 502 SHIPMENT.PURCHASE_UNKNOWN
curl -X POST …/v1/shipments/shp_…/reconcile                            # status: created
curl -X POST …/v1/shipments/shp_…/buy -d '{"service":"FEDEX_GROUND"}'   # 200

# A delivery that goes wrong
curl -X POST …/v1/shipments -d '{"to_address":{"street2":"__scenario:return_to_sender",…},"service":"FEDEX_GROUND",…}'
sleep 35; curl -X POST …/v1/trackers/trk_…/refresh                      # status: return_to_sender
```

## Testing international shipments

The mock FedEx, USPS and UPS quote and buy international parcels in test mode. Send a foreign
`to_address` with [`customs_info`](https://ship.vprev.dev/docs/md/api.md#international-shipping): the quote has each carrier's
international services, and the purchase stores a mock label and, for FedEx and UPS, a mock commercial
invoice marked "not valid for shipping", listed in `forms` like a real one. The mock USPS prints the
declaration on its label, as USPS does.
Everything that's checked before a carrier is called behaves exactly as in live mode, so you can test
`SHIPMENT.CUSTOMS.REQUIRED`, `SHIPMENT.CUSTOMS.INVALID` and `SHIPMENT.CUSTOMS.EEI_REQUIRED` here.

MockEx only ships within the US. With a MockEx account, an international quote lists it in
`carrier_errors` with `CARRIER.UNSUPPORTED_DESTINATION`, as a domestic-only carrier would.

```bash
curl -X POST https://ship.vprev.dev/v1/shipments -H "Authorization: Bearer sk_test_…" -H "Content-Type: application/json" -d '{
  "to_address": { "name": "Test Recipient", "street1": "10 Example Road", "street2": "__scenario:customs_rejected", "city": "London", "zip": "SW1A 1AA", "country": "GB", "phone": "+44 20 7946 0000" },
  "parcel": { "weight": 12 },
  "customs_info": { "customs_items": [{ "description": "Cotton T-shirt", "quantity": 2, "value": "40.00", "weight": 8 }] },
  "service": "INTERNATIONAL_ECONOMY"
}'   # 422 CARRIER.ERROR, details[0].code SCENARIO.CUSTOMS_REJECTED
```

## Testing shipping rules

[Shipping rules](https://ship.vprev.dev/docs/md/rules.md) have their own set per mode, so test mode is the place to build and try them: nothing reaches a live shipment until you
**Copy to live**. Everything below works with a `sk_test_` key or the admin's test mode.

**Get a second carrier.** Rules that exclude, require or prefer a carrier need more than one to show anything. Test mode already has the mock FedEx; add the
credential-free mock carrier **MockEx**:

```bash
curl -X POST https://ship.vprev.dev/v1/carrier_accounts -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" -d '{"carrier":"mockex"}'
```

MockEx offers `MOCKEX_GROUND` (5 days, $8 plus $0.70 per lb) and `MOCKEX_EXPRESS` (2 days, $30 plus $3 per lb). It supports declared value but **not** signatures or Saturday delivery,
so when a rule sets one of those, MockEx drops out of the quote with a `CARRIER.UNSUPPORTED_OPTION` entry in `carrier_errors`. That is realistic: the same happens when a real carrier can't do what a rule asks.
A destination ZIP of `00001` makes MockEx fail and `00002` makes it slow.

**Try rules without saving them.** `POST /v1/rules/test` takes `rules` you haven't saved and a sample `input`, and with `quote: true` shows the rates that survive the limits; in test mode those are the mock
carriers' rates. The admin's **Try it** does the same. See [Testing rules](https://ship.vprev.dev/docs/md/rules.md#testing-rules).

**Combine rules with scenarios.** Scenarios change what the carriers do, rules change what you ask of them, so you can see how a rule set behaves when things go wrong:

| Try | What to expect |
| --- | --- |
| A rule `only_carriers: ["mockex"]`, header `VeloShip-Scenario: carrier_down@mockex` | The only carrier allowed is down: `502 CARRIER.ERROR`, not `RULES.NO_RATE`. Rules removed nothing; the carrier failed |
| A rule `exclude_carriers: ["fedex"]` with a MockEx account, header `VeloShip-Scenario: carrier_down@fedex` | The quote succeeds: FedEx is never called, so its outage doesn't matter. `excluded_rates` lists FedEx with `service: null` |
| A rule `max_price` of `"5.00"` | Every mock rate is above $5, so `422 RULES.NO_RATE` lists each rate and the reason |
| `VeloShip-Scenario: no_rates` with rules that only change the shipment | `200` with `rates: []`: the carriers returned nothing, so rules had nothing to remove and there is no `RULES.NO_RATE` |
| A `block` rule and `POST /v1/shipments` with `service` | `422 RULES.BLOCKED` before anything is created or bought |
| `exclude_services: ["FEDEX_GROUND"]`, then `POST /v1/shipments/:id/buy {"service":"FEDEX_GROUND"}` | `422 RULES.SERVICE_EXCLUDED` |

**A walk-through.**

```bash
# 1. A rule: orders over $100 need a signature
curl -X POST …/v1/rules -d '{"name":"Signature over $100","when":{"all":[{"field":"order.value","op":"gt","value":"100.00"}]},"then":[{"type":"set_signature","signature":"direct"}]}'
# 2. A quote with an order that triggers it: applied_rules shows the signature, and the MockEx account is missing from rates (it can't do signatures)
curl -X POST …/v1/rates -d '{"to_address":{…},"parcel":{"weight":16},"order":{"value":"149.00"}}'
# 3. The same quote with options.signature "none": the rule is kept out ("status": "kept") and MockEx is back
curl -X POST …/v1/rates -d '{"to_address":{…},"parcel":{"weight":16},"order":{"value":"149.00"},"options":{"signature":"none"}}'
# 4. Happy with it? In test mode, replace the live rules with these (owners and admins)
curl -X POST …/v1/rules/copy_to_live
```

Rules and scenarios are separate features, so a `VeloShip-Scenario` header never changes which rules match. The weekday and date rules use the **real clock** in UTC (rules
can't be set to another date); test them with a dry run's `rules`, or by writing the rule's date window around today.

## Webhooks

There is no scenario for a failing webhook endpoint; the real thing is the test. Point a webhook at
an endpoint you control (and make it fail, time out or return 500), then:

- `POST /v1/webhooks/:id/test` queues a `webhook.test` event.
- `GET /v1/webhooks/:id/deliveries` shows each attempt's status, response code and error, with
  retries backing off up to 8 attempts.
- `POST /v1/webhook_deliveries/:id/redeliver` retries one by hand.

Combine with the tracking scenarios to get `tracker.updated` and `shipment.*` events on demand.

## Seeing what happened

Every request is recorded: open **More → Developers → Request log** in the admin, or call
`GET /v1/request_logs`. Entries show the scenario that applied, the error code and the redacted
bodies, so you can see exactly what a scenario returned to your integration.
