# Record-Triggered Flow E2E Scenario Test Plan

skip: true

**Source Bug/PR:** #1106 (standard-relationship-resolution + AfterSave recordUpdate fix)
**Parent Epic:** #1080 (Flow Engine gap fix — 7 GAPs)
**Reference Issues:** #1120, #1121, #1122, #1123, #1124, #1125 (follow-up audit / secondary bugs)
**Test Environment:** new-prod-essential TiDB branch (or staging equivalent)
**Date:** 2026-04-17

---

## Why this scenario exists

PR #1106 shipped two overlapping fixes that only an end-to-end run against a real DB can catch:

1. **Standard relationship lazy-load**: `$Record.Account.Name` (standard, no `__r` suffix) must resolve the `AccountId` FK via `standardRelationships` and lazy-load the Account row. Before the fix, such references silently returned nil and any assignment that depended on them wrote null.

2. **AfterSave recordUpdate must persist via DB Update**: the earlier `!shouldPersistFlowInterview(ctx)` gate collapsed to "any record-triggered flow" under the listener's transient-interview wrapper, so AfterSave `recordUpdate` on implicit `$Record` silently mutated an in-memory record the listener never merged back. Silent data loss.

Unit tests and go-reviewer gate-review catch the code shape. Only a real DB + real flow registry + real Outbox worker can catch either regression in the full path. Hence these E2E cases under `tests/e2e-api/cases/flow/`.

---

## Pre-conditions

| ID | Check | Expected | How to verify |
|----|-------|----------|---------------|
| PRE-01 | Seed `Account_0` exists with a **non-empty Name** | e.g. `(ISV)Webank` | `SELECT Id, Name, PLG_BD_Pool__c FROM Account WHERE Id='{seed_Account_0}'` |
| PRE-02 | A known Account with `PLG_BD_Pool__c=true` exists (FLOW-003/004 hard-code its Id as the `Account_PLG_Id` case-level variable) | — | `SELECT Id, PLG_BD_Pool__c FROM Account WHERE Id='{Account_PLG_Id}'` returns `1` — update the variable per environment |
| PRE-03 | `Order_Account_Name` flow is **active** (`RecordAfterSave`, `CreateAndUpdate`, object=Order) | — | `SELECT DeveloperName, TriggerType, ActiveVersionId FROM FlowDefinition WHERE DeveloperName='Order_Account_Name'` |
| PRE-04 | `Order_Set_PLG_BD_Pool` flow is **active** (`RecordBeforeSave`, `CreateAndUpdate`, object=Order) | — | Same query with `DeveloperName='Order_Set_PLG_BD_Pool'` |
| PRE-05 | `Contract_PingCAP_Contracting_Entity` flow is **active** (`RecordAfterSave`, `CreateAndUpdate`, object=Contract) | — | Same query with `DeveloperName='Contract_PingCAP_Contracting_Entity'` |
| PRE-06 | `Account_0.PingCAP_Contracting_Entity__c` is **non-null** | e.g. `PingCAP Pte. Ltd.` | Guarantees the Contract AfterSave decision takes the populate branch. |
| PRE-07 | `_System_ConnectedApp.RunAsUserId` set on `nexuscrm-ui` | non-null user Id with RunFlow perms | `client_credentials` grant works. |
| PRE-08 | Outbox worker is running | log line `📤 Outbox worker started with 500ms interval` at backend startup | AfterSave flows fire via Outbox; without worker they never persist. |

---

## Scenario 1: AfterSave recordUpdate hits DB Update (the PR #1106 end-to-end fix)

> Locks the two-defect chain together: standard-rel lazy-load + AfterSave DB Update.

| ID | Step | Expected | Checkpoints | Related Issue |
|----|------|----------|-------------|---------------|
| S1-01 | `POST /Order` with `AccountId={seed_Account_0}` + all 13 required fields | HTTP 201, Order id returned | Insert completes synchronously | PR #1106 |
| S1-02 | Wait for Outbox worker to drain `record.created` event | `_System_OutboxEvent` row transitions to `processed` within ~30s | No `FAILURE` status | PR #1106, #1080 GAP 2 |
| S1-03 | Fetch Order, assert `Account_Name_for_email_template__c == Account_0.Name` | non-null, equal to seed Account's Name | Order_Account_Name flow ran, Account was lazy-loaded, recordUpdate persisted via DB | PR #1106 |

Test case: `cases/flow/flow-001.json`, `cases/flow/flow-005.json`

---

## Scenario 2: AfterSave with IsBlank / IsNotBlank condition + cross-object field read

> Locks IsBlank semantics (PR #1080 GAP 7) + arbitrary custom field via standard-rel lookup.

| ID | Step | Expected | Checkpoints | Related Issue |
|----|------|----------|-------------|---------------|
| S2-01 | `POST /Contract` with `AccountId={seed_Account_0}`, `Status=Draft`, `StatusCode=Draft`, `StartDate={today}`, `ContractTerm=12` | HTTP 201 | Contract.PingCAP_Contracting_Entity__c initially null at insert time | PR #1080 GAP 7, PR #1106 |
| S2-02 | Wait for AfterSave flow chain (Contract_PingCAP_Contracting_Entity) | ~5–30s | Decision rule fires: `Account.PingCAP_Contracting_Entity__c IsNull=false AND $Record.PingCAP_Contracting_Entity__c IsNull=true` | #1080 |
| S2-03 | Fetch Contract, assert `PingCAP_Contracting_Entity__c == Account_0.PingCAP_Contracting_Entity__c` | non-null, matches Account's value | recordUpdate with implicit $Record hit persistence.Update | PR #1106 AfterSave |

Test case: `cases/flow/flow-002.json`

---

## Scenario 3: BeforeSave in-place assignment (Create path)

> Locks the BeforeSave context marker + in-place branch + listener merge-back.

| ID | Step | Expected | Checkpoints | Related Issue |
|----|------|----------|-------------|---------------|
| S3-01 | `POST /Order` with `AccountId={Account_PLG_Id}` (PLG=1) and `PLG_BD_Pool__c=false` in payload | HTTP 201 | BeforeSave flow Order_Set_PLG_BD_Pool assigns `$Record.PLG_BD_Pool__c = $Record.Account.PLG_BD_Pool__c` | PR #1106 |
| S3-02 | Fetch Order immediately (no wait — BeforeSave is synchronous) | `PLG_BD_Pool__c == true` despite payload having sent `false` | Listener's merge-back ran, persisting the flow's $Record mutation | PR #1106 |

Test case: `cases/flow/flow-003.json`

---

## Scenario 4: BeforeSave in-place assignment (Update path)

> Locks the BeforeUpdate arm — `isBeforeSaveFlow` marker must also fire on RecordBeforeUpdate events, not just BeforeCreate.

| ID | Step | Expected | Checkpoints | Related Issue |
|----|------|----------|-------------|---------------|
| S4-01 | Create Order with `AccountId={Account_PLG_Id}` | HTTP 201, `PLG_BD_Pool__c=true` | Setup — same as S3 | — |
| S4-02 | `PATCH /Order/{id}` with `AccountId={seed_Account_0}` | HTTP 204 | Triggers RecordBeforeUpdate → BeforeSave flow re-runs on updated record | PR #1106 |
| S4-03 | Fetch Order, assert `PLG_BD_Pool__c == false` (Account_0 has PLG=0) | — | BeforeSave assigned the new Account's value | PR #1106 |

Test case: `cases/flow/flow-004.json`

---

## Scenario 5: Scheduler + Wait element schema smoke (no runtime trigger)

> Integration check — full Wait runtime requires time-travel of `PausedUntil`, which is out of scope here. Instead, verify the static wire-up that #1080 GAP 2 delivers.

| ID | Step | Expected | Checkpoints |
|----|------|----------|-------------|
| S5-01 | Backend log at startup contains `📤 Outbox worker started` and `⚙️ Background workers started (Outbox, Scheduler, BulkProcessor)` | Both lines present | Scheduler is running |
| S5-02 | `SHOW COLUMNS FROM FlowInterview LIKE 'PausedUntil'` returns one row of type `timestamp` | Column exists | Schema has PausedUntil |
| S5-03 | Same for `ClaimedBy varchar(64)` and `ClaimedAt timestamp` | Columns exist | Claim lease for concurrent-scheduler-safe resume |
| S5-04 | Backend log shows recurring query every ~60–90s: `SELECT ... FROM FlowInterview WHERE InterviewStatus = 'Paused' AND PausedUntil <= ?` | Query fires on cadence | Scheduler polls as designed |

**Manual / infra test** — no JSON case. Runs via log inspection after backend boot.

---

## Scenario 6: `TODAY()` formula arithmetic

> Verifies formula engine rewrites `TODAY()+N` → `DATE_ADD(TODAY(), N)` (PR #1080 GAP 3).

| ID | Step | Expected | Checkpoints |
|----|------|----------|-------------|
| S6-01 | Call `formula.Engine.Evaluate("TODAY()+30", ctx)` | Returns `{today+30}` as `string` in `YYYY-MM-DD` form | Unit test: `backend/pkg/formula/engine_test.go::TestEngine_Evaluate/TODAY function` |
| S6-02 | Call `Evaluate("TODAY()-7", ctx)` | Returns `{today-7}` | Same test family |

**Go unit test — not a JSON E2E case.** Added here for scenario completeness; live in `backend/pkg/formula/engine_test.go`.

---

## Running the cases

```bash
cd tests/e2e-api

# Ensure seed_data.json has Account_0, Account_PLG keys wired to real Account Ids
# that satisfy PRE-01 through PRE-06 above.

python3 runner.py --scene flow --skip-setup
```

The `flow` scene has `retry` + `delay_ms` on its `query_after` blocks so AfterSave
flows that go through Outbox (Scenarios 1 and 2) can settle before the assertion
fires. If a run fails with `Field X should not be null` on an AfterSave assertion,
the first thing to check is the Outbox worker log — the event may be in `failed`
state with a retry-exceeded error.

---

## Known constraints

- **Scheduled flows (S5)** and **subflow chain invocation (#1080 GAP 5)** are not fully runnable end-to-end without time manipulation or invocable-flow REST access. Treat them as covered by integration / unit tests plus metadata structural checks.
- **Formula engine defects** (#1123, #1125) are deliberately NOT locked into these cases — they would cause FLOW-* cases to fail for reasons unrelated to PR #1106's actual scope. When those issues are fixed, add focused cases separately.
- **Account_PLG_Id** is a case-level `variables` entry in FLOW-003/004 with a hard-coded Account Id. The chosen Id must satisfy `PLG_BD_Pool__c=true` in whatever DB the runner targets. Update the value per environment (on new-prod-essential, `0015g00000OmZRxAAN` / Formagrid Inc fits).
- **`{seed_Account_0_Name}` reference in FLOW-005** resolves via the 3-segment seed-lookup form added in this PR. No seed_data.json schema change needed — `seed_data.account[0].Name` is read directly.
- **Wall-clock budget** — AfterSave cases (FLOW-001/002/005) carry `retry=15–20, delay_ms=2000`, so a single case can block up to 40s waiting for Outbox settle. Worst-case total for the 5-case scene is ~110s. When wiring into CI, bump the job's `timeout-minutes` or keep `flow` out of the fast-path lane.
