# E2E API Case Repair Playbook

## Purpose

This document captures the patterns that worked while repairing the current
stable scenes in `tests/e2e-api` so other agents can continue fixing the
remaining cases in parallel without rediscovering the same failures.

Current stable scenes:

- `product`
- `opportunity_line`

These scenes are the ones currently used by `make test-e2e-api` and the
`e2e-api-tests.yml` workflow.

In-progress repair targets with strong first-wave patterns:

- `quote`
- `order`

## Current Execution Model

The current E2E API flow no longer provisions TiDB Cloud Zero.

It runs like this:

1. Start the backend locally.
2. Point the backend at a shared TiDB database or a prepared local TiDB DB.
3. Run repaired scenes with `runner.py --skip-setup`.
4. Leave the runner exit code at `0` by default because the broader suite is
   still under repair.

The GitHub Actions workflow uses the `staging` environment and reads:

- `TF_VAR_TIDB_HOST`
- `TF_VAR_TIDB_USER`
- `TF_VAR_TIDB_PASSWORD`
- optional `TF_VAR_TIDB_DATABASE`
- `NEXUSCRM_PASSWORD` or `NEXUS_ADMIN_PASSWORD`

## Why Legacy Cases Broke

Several legacy scenes assumed a test environment that no longer matches the
current backend and DB behavior:

- Seed placeholders such as `{seed_Product2_0}` or `{seed_PricebookEntry_0}`
  referred to tables or rows that were missing or stale.
- Some shared remote records were mutated in-place, so parallel runs collided.
- The current DB schema has stricter non-null/default behavior than the old seed
  loader expected.
- Some objects trigger automation on create/update, so using shared records or
  relying on implicit defaults became flaky.
- `PATCH` cases often expected a record ID from the response body, but `204`
  responses do not return one.

## Repair Pattern

### 1. Make each case self-contained

Do not depend on shared seed rows unless the scene is explicitly about shared
fixture data.

Preferred pattern:

- create temporary records in `setup`
- use unique values based on `run_uuid`
- clean up in `teardown`

### 2. Use the built-in runtime variables

The runner now provides per-case variables:

- `{run_uuid}`
- `{timestamp}`
- `{today}`
- `{db_timestamp}`
- `{billing_country_code}`

Use `run_uuid` in names, external keys, or SQL-created IDs so parallel runs do
not collide.

Use `billing_country_code` for Quote / Order prerequisite Accounts whose
compound autonumber prefixes depend on `Account.BillingCountryCode`. Reusing
`US` across every CI run piles unrelated cases onto the same per-prefix/day
counter and eventually overflows two-digit formats such as `Quote.Name`.

### 3. Use SQL setup only for isolated prerequisite records

Use SQL setup when REST creation is unreliable because business automation or
validation interferes with the object you only need as a prerequisite.

This is the current recommended pattern for `Opportunity` in the repaired
`opportunity_line` cases.

Example:

```json
{
  "type": "sql",
  "sql": "INSERT INTO Opportunity (Id, Name, CloseDate, ForecastCategory, StageName, Probability, IsDeleted, CreatedDate, LastModifiedDate, SystemModstamp) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
  "params": [
    "{run_uuid}",
    "Temporary Opportunity {run_uuid}",
    "2026-04-30",
    "Pipeline",
    "PROSPECTING",
    10,
    0,
    "{db_timestamp}",
    "{db_timestamp}",
    "{db_timestamp}"
  ]
}
```

Use REST for the behavior under test whenever possible.

### 4. Prefer reverse-order teardown

Delete children first, then parents.

Typical order for `opportunity_line`:

1. `OpportunityLineItem`
2. `PricebookEntry`
3. `Pricebook2`
4. `Product2`
5. SQL delete of the temporary `Opportunity`

### 5. For `PATCH` cases, set `query_after.record_id` explicitly

If the main request returns `204`, the runner cannot infer the record ID from
the response body.

Use:

```json
"query_after": {
  "object": "Opportunity",
  "record_id": "{run_uuid}",
  "assertions": [...]
}
```

This fixed `OLI-006`.

### 6. Use `response_assertions` for query endpoints

If the main response is a SOQL query result, validate the body directly instead
of forcing `query_after`.

Example:

```json
"response_assertions": [
  { "field": "totalSize", "operator": "gt", "value": 0 },
  { "field": "records.0.Product2Id", "operator": "eq", "value": "{product_id}" }
]
```

### 7. For Quote and Order regressions, assert the tenant-backed line item objects

Do not aim repaired Quote/Order cases at Salesforce's stock
`QuoteLineItem` / `OrderItem` tables unless the current implementation truly
uses them.

The current backend path is:

1. `OpportunityLineItem` -> `quote_product__c` on Quote create
2. `quote_product__c` -> `order_product__c` on Order create

Recommended `query_after` patterns:

```json
"query_after": {
  "object": "quote_product__c",
  "id_field": "Quote__c",
  "record_id": "{quote_id}",
  "retry": 3,
  "delay_ms": 2000,
  "assertions": [
    { "field": "Quote__c", "operator": "eq", "value": "{quote_id}" },
    { "field": "Product__c", "operator": "eq", "value": "{product_id}" }
  ]
}
```

```json
"query_after": {
  "object": "order_product__c",
  "id_field": "Order__c",
  "record_id": "{order_id}",
  "assertions": [
    { "field": "Order__c", "operator": "eq", "value": "{order_id}" }
  ]
}
```

This keeps the regression pointed at the implementation that actually ships
today and avoids false confidence from querying the wrong related object.

### 8. Quote scene recipe: self-contained pricing prerequisites

The current stable setup recipe for Quote-copy regressions is:

1. SQL-create an isolated `Account`
2. SQL-create an isolated `Opportunity`
3. REST-create temporary `Product2`
4. REST-create temporary `Pricebook2`
5. REST-create temporary `PricebookEntry`
6. SQL-create an `OpportunityLineItem` that references the temporary
   `PricebookEntry` and carries the price fields you expect to be copied
7. POST the `Quote`
8. Query `quote_product__c` by `Quote__c`

Why this split works:

- `Product2` / `Pricebook2` / `PricebookEntry` are stable through the public
  API and stay isolated per run.
- `OpportunityLineItem` setup is more reliable through SQL because some quote
  regressions need custom fields such as `Unit_Net_Price__c`,
  `Net_Fees__c`, and service dates to be present exactly as seeded.

Use reverse-order teardown:

### 9. Approval-ready request chaining

The runner can now execute chained HTTP requests inside one case.

Use this when a regression needs:

- submit, then approve
- approve twice
- the same API path under different users

Supported patterns:

- legacy fields:
  - `approval_step`
  - `retry_approval`
- generic form:
  - `request_steps`

Each HTTP step can also carry:

- `expects`
- `response_assertions`
- `extract`
- `timeout_seconds`
- `auth`

Example:

```json
"approval_step": {
  "name": "Approve as sales user",
  "endpoint": "/services/data/v63.0/process/approvals",
  "method": "POST",
  "auth": {
    "username": "sales@test.com",
    "password": "Sales123!"
  },
  "payload": {
    "actionType": "Approve",
    "contextId": "{quote_id}"
  },
  "expects": {
    "status_code": 403
  }
}
```

`expects.body_contains` now accepts all three shapes below:

- string
- list of strings
- field-to-value map

1. `quote_product__c`
2. `Quote`
3. `OpportunityLineItem`
4. `PricebookEntry`
5. `Pricebook2`
6. `Product2`
7. `Opportunity`
8. `Account`

### 9. Order gate recipe: metadata lookup before Contract seed

The positive and negative `Order` gate cases depend on seeded Contract
RecordTypes. Do not hard-code a RecordTypeId.

Recommended first setup step:

```json
{
  "type": "sql",
  "sql": "SELECT rt.Id AS csa_rt_id FROM _System_RecordType rt JOIN _System_Object so ON so.Id = rt.ObjectId WHERE so.ApiName IN ('Contract','contract') AND rt.Name = 'TiDB Cloud Services Agreement' AND rt.IsActive = 1 AND (rt.IsDeleted = 0 OR rt.IsDeleted IS NULL) LIMIT 1",
  "fetch": "one",
  "extract": {
    "csa_rt_id": "csa_rt_id"
  }
}
```

If the Order autonumber depends on `RecordType.record_type_code`, repair the
case by making that code idempotently non-empty before inserting the `Order`:

```json
{
  "type": "sql",
  "sql": "UPDATE _System_RecordType SET record_type_code = 'CSA' WHERE Id = %s AND (record_type_code IS NULL OR record_type_code = '')",
  "params": ["{csa_rt_id}"]
}
```

For negative gate cases, assert the exact user-facing error message with
`response_assertions` instead of stopping at `status_code = 400`.

### 10. Distinguish "case incomplete" from "product gap exposed"

When a repaired case has already reached all of these qualities:

- isolated setup
- explicit teardown
- strong readback assertions
- verified field names against live `describe`

but still fails on a business invariant, treat it as a likely product gap
rather than immediately weakening the case.

Current example:

- `quote/R-565`
  - the case is structurally complete
  - `Discount__c` and `Unit_Net_Price__c` populate correctly
  - `quote_product__c.Net_fees__c` still reads back as `null`

Recommended handling:

1. mark the case `skip: true`
2. write a precise `skip_reason` that names the missing invariant
3. keep the strong assertion in the file
4. treat the follow-up as product-fix work, not test-case cleanup

## Runner Features Added During Repair

`runner.py` now supports the following repair-friendly features:

- built-in runtime variables: `run_uuid`, `timestamp`, `today`, `db_timestamp`
- `setup` as either an object or a list
- `teardown` as either an object or a list
- teardown execution in `finally`
- SQL setup/teardown steps
- dotted path assertions such as `records.0.Id`
- `response_assertions` for the main response body
- operators: `equals`, `gt`, `not_contains`
- comma-separated scene filters, for example `--scene product,opportunity_line`
- `query_after.record_id` and `query_after.id_field`
- optional `--fail-on-failures` CLI flag

## Validation Commands

Run a repaired scene directly:

```bash
cd tests/e2e-api
python3 runner.py --scene product --skip-setup
python3 runner.py --scene opportunity_line --skip-setup
```

Run the current stable suite through the shared wrapper:

```bash
DB_HOST=<host> \
DB_PORT=4000 \
DB_USERNAME=<user> \
DB_PASSWORD=<password> \
DB_DATABASE=nexuscrm \
NEXUSCRM_PASSWORD=<admin-password> \
make test-e2e-api
```

## What To Avoid

- Do not mutate shared business records for update cases.
- Do not rely on old `{seed_*}` placeholders for `Product2`, `Pricebook2`,
  `PricebookEntry`, or `OpportunityLineItem`.
- Do not assume the runner will discover an ID after `PATCH`.
- Do not leave teardown incomplete when a case creates temporary records.
- Do not convert everything to SQL. Use SQL only for prerequisites that are hard
  to create safely through the public API.

## Recommended Next Targets

Look for scenes with one or more of these patterns:

- hard-coded seed IDs for pricing or quote/order objects
- update cases against shared remote records
- query cases that should be converted to `response_assertions`
- setup data that can be isolated with `run_uuid`

The next agent should repair one scene at a time and keep each repaired scene
fully parallel-safe before moving on.
