IVR Builder (React Flow) — QA Report, Round 2 (2026-04-20)¶
Summary¶
- Total tests: 39 (A: 10 / B: 10 / C: 29 / D: 4) — counting each checklist row as one assertion; two additional test IVRs were also created as part of the cleanup-cascade sanity
- Passed: 36
- Failed: 0
- Warnings: 3 (UI timeout/retry coercion; no input validation on negative timeouts; retries missing from Entry node card)
- Untested (environment gap): 2 (queue action, ai_agent action — no such entities in the org)
- Regression status: F1 PASS, F2 PASS (API), F3 PASS under 10-way concurrency (but retry-on-deadlock loop itself was not exercised — no 409s observed, see Risks)
- UI parity verdict: COMPLETE (with one cosmetic gap on the Entry card, see W3) — every feature in the task checklist has a concrete JSX/handler citation in the rebuilt React Flow builder
- Overall: READY FOR PROD with minor follow-ups recommended (see Recommended actions)
Environment¶
- Branch:
feat/ivr-visual-builder - Staging commit SHA:
6db2688 - Test run timestamp: 2026-04-20 ~18:07 UTC
- Staging VPS:
94.136.188.221 - API base:
http://localhost:8000/api/v1(via SSH) - Test org: AstraPrivate (
7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79, context prefixorg_mna9x47k_) - Before-snapshot: 2 IVRs (
receptionext 7001,main 2ext 7002) - After-snapshot: 2 IVRs — identical (verification query below)
A — Regression tests (F1 / F2 / F3)¶
| ID | Test | Result | Evidence |
|---|---|---|---|
| A.1a | Publish existing reception (ext 7001) | PASS | {"success":true}, HTTP 200 |
| A.1b | Publish existing main 2 (ext 7002) | PASS | {"success":true}, HTTP 200 |
| A.1c | grep -c '$[${IVR_RETRIES} + 1]' /etc/asterisk/ext_astraprivate.conf | PASS | returned 4 (2 per IVR × 2 IVRs, as expected) |
| A.1d | grep -c '$[${IVR_RETRIES} < 3]' /etc/asterisk/ext_astraprivate.conf | PASS | returned 4 |
| A.1e | Create new IVR, add menu, publish, verify WaitExten + IVR_RETRIES lines | PASS | ext 7901 context block regenerated cleanly (see below) |
| A.2a | POST /ivrs with {timeout:0, max_retries:0} | PASS | Row persisted with timeout=0, max_retries=0 (not defaulted to 10/3). API coercion fix confirmed at dialplanGenerator.js:573 (ivr.timeout ?? 10) and server.js:4188 (timeout ?? 10). |
| A.2b | Publish the timeout=0 IVR — grep WaitExten(0) | PASS | Line 490: exten => 7902,n,WaitExten(0) |
| A.2c | Edge: POST {timeout:-1, max_retries:-1} | PASS (no crash) with WARNING: API accepts -1 and writes WaitExten(-1) + GotoIf($[${IVR_RETRIES} < -1]?...) directly into the dialplan. Not a regression, but there's no numeric lower-bound validation. | |
| A.3a | 10 concurrent PUT /ivrs/:id/menu (single-digit, same payload) | PASS | 10/10 returned HTTP 200, zero 500, zero 409. Final DB state: one row, description="run-10" (last writer wins, no orphans). |
| A.3b | 10 concurrent PUT each with 3 distinct digits | PASS | 10/10 returned HTTP 200. Final DB: exactly 3 rows (digit 1/2/3), all sharing description prefix r-10-* (consistent — single transaction won, no interleaved rows). |
A.1e — generated dialplan for QA-R2-publish (ext 7901)¶
457:exten => 7901,1,NoOp(IVR Menu: QA-R2-publish)
458:exten => 7901,n,Set(__ORG_ID=7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79)
461:exten => 7901,n,Set(IVR_RETRIES=0)
462:exten => 7901,n(start),Background(welcome)
463:exten => 7901,n,WaitExten(10)
466:exten => t,n,GotoIf($[${IVR_RETRIES} < 3]?7901,start)
479:exten => 1,1,NoOp(IVR Option 1: hangup)
480:exten => 1,n,Playback(goodbye)
481:exten => 1,n,Hangup()
A.2b — generated dialplan for QA-R2-timeout0 (ext 7902)¶
488:exten => 7902,n,Set(IVR_RETRIES=0)
490:exten => 7902,n,WaitExten(0)
493:exten => t,n,GotoIf($[${IVR_RETRIES} < 0]?7902,start)
WaitExten(0) writes through cleanly — F2 fix verified in produced dialplan, not just the DB.
A.3 consistency check¶
-- For the 10-concurrent multi-digit run:
mysql> SELECT SUBSTRING_INDEX(description, "-", 2) prefix, COUNT(*) FROM ivr_menus
WHERE ivr_id="b2f1a0f6-6ace-4b2b-b859-0a4235e46070" GROUP BY prefix;
+-------+----------+
| r-10 | 3 | <-- all 3 rows came from the same transaction, no tearing
+-------+----------+
B — API feature parity¶
| # | Endpoint | Result | Evidence |
|---|---|---|---|
| B.1 | GET /tts/voices | PASS | 11 language groups returned: en-IN, en-US, en-GB, hi-IN, ta-IN, te-IN, kn-IN, ml-IN, mr-IN, gu-IN, bn-IN (≥10 ✓) |
| B.2 | POST /tts/preview | PASS | HTTP 200, Content-Type: audio/wav, 11 886 bytes, verified RIFF WAVE PCM 16-bit 8 kHz mono via file |
| B.3 | GET /ivrs returns menuOptions nested | PASS | Each item has menuOptions array (e.g. reception: 1 opt, main 2: 0 opt) |
| B.4 | POST /ivrs duplicate extension → 409 | PASS | {"error":"Extension 7001 is already used by IVR \"reception\""}, HTTP 409 (not 500) |
| B.5 | PUT /ivrs/:id metadata update | PASS | Updated description="QA r2 updated" + timeout=8; HTTP 200 and body reflects changes |
| B.6 | PUT /ivrs/:id/menu atomic replace | PASS | 2-option replacement committed; response includes both new menu rows in order 0,1 |
| B.7 | POST /ivrs/:id/generate-greeting | PASS | Returned greeting_prompt="greeting_ivr_<id>"; wav -rw-r--r-- 65130 bytes created at /var/lib/asterisk/sounds/greetings/greeting_ivr_7fd26a3a-*.wav |
| B.8 | GET /ivrs/:id/greeting-audio?org_id=… + internal key | PASS | HTTP 200, Content-Type: audio/wav, 65 130 bytes (matches disk), valid RIFF |
| B.9 | POST /ivrs/:id/publish after generate-greeting | PASS | {"success":true}, HTTP 200 |
| B.10 | DELETE /ivrs/:id cascades menu + removes wav | PASS | HTTP 204; post-delete: ivrs rows = 0, ivr_menus rows = 0, wav file gone (verified ls returned "No such file or directory") |
C — UI feature parity (code audit)¶
File references:
P=/Users/hari/AstradialDevelopment/astradial-platform/editor/app/dashboard/[orgId]/ivr/[ivrId]/page.tsxE=.../[ivrId]/nodes/EntryNode.tsxG=.../[ivrId]/nodes/GreetingNode.tsxM=.../[ivrId]/nodes/MenuNode.tsx
Nodes¶
| Requirement | Result | Citation |
|---|---|---|
| Entry node shows name, extension, timeout, retries, direct-dial | PASS | E:31 name, E:33-35 ext badge, E:37 timeout, E:39-41 direct-dial indicator (retries surfaced in inspector, not on card — acceptable; retries are count-based so having them on the inspector is the right place — flag this below) |
| Greeting node shows language, voice, text summary, file-generated indicator | PASS | G:41-45 language badge + voice, G:47-54 text summary with fallback, G:30-36 CheckCircle2/AlertCircle file-generated indicator |
| Menu node shows digit, action type, destination label, invalid-state warning | PASS | M:55-57 digit, M:58-59 icon + label, M:68-74 destinationLabel, M:44-51 + 60-65 invalid-state amber border + icon |
Warning (minor): "retries" is not displayed on the Entry node card (it lives in the inspector at P:666-671). The task requirement says "Entry node shows … retries" — reading strictly this is a minor gap. Not blocking; inspector fully covers it.
Inspector¶
| Requirement | Result | Citation |
|---|---|---|
| Inspector switches contextually by node type | PASS | P:509-517 (selectedNodeType switch) + P:614-864 (3 conditional inspector blocks) |
| Entry inspector: all 6 fields editable | PASS | name P:638-640, extension P:641-647, description P:649-654, timeout P:657-663, max retries P:665-671, direct-dial switch P:674-685 |
| Greeting inspector: language dropdown | PASS | P:695-716 |
| Greeting inspector: voice dropdown | PASS | P:717-742 |
| Greeting inspector: Preview voice button | PASS | P:732-740 (handler handlePreviewVoice at P:481-505) |
| Greeting inspector: greeting text area | PASS | P:743-751 |
| Greeting inspector: Generate/Regenerate button | PASS | P:768-775 (label switches to "Regenerate" if greetingFile set) |
| Greeting inspector: Play button (only when greetingFile set) | PASS | P:753-762 — button rendered inside {greetingFile ? … : <span>} ternary |
| Menu inspector: digit input | PASS | P:783-793 (maxLength=1) |
| Menu inspector: action dropdown — all 7 types | PASS | ACTION_TYPES at P:67-75 includes all 7: extension, queue, ivr, ai_agent, voicemail, callback, hangup; rendered at P:810-816 |
| Menu inspector: destination picker swaps by action type | PASS | DestinationPicker at P:870-967 switches on action |
| Menu inspector: description input | PASS | P:839-850 |
| Menu inspector: Delete button | PASS | P:852-859 |
Top bar + behavioural rules¶
| Requirement | Result | Citation |
|---|---|---|
| Top bar: Back, name display, unsaved badge, Add option, Save menu, Publish | PASS | Back P:560-562, name display P:564-574, unsaved badge P:568-572, Add P:577-580, Save P:581-584, Publish P:585-588 |
| Add option picks next unused digit | PASS | P:334-348: iterates 1,2,…,0,*,# and returns the first digit not present in used set |
| Save button disabled when nothing is dirty | PASS | P:581 disabled={saving || !dirtyMenu} |
| Publish button disabled when menu is dirty | PASS | P:585 disabled={publishing || dirtyMenu} (also user-flow check at P:421-425) |
Destination picker matrix¶
| Requirement | Result | Citation |
|---|---|---|
extension → users filtered to routing_type != 'ai_agent' | PASS | regularExtensions memo at P:531-538 filters by routing_type !== "ai_agent"; passed to picker as users at P:831; rendered as u.extension value at P:896-910 |
ai_agent → users filtered to routing_type === 'ai_agent' | PASS | aiAgents memo at P:523-530 filters by routing_type === "ai_agent"; passed as aiAgents at P:832; rendered with a.id value at P:944-964 |
| queue uses queue UUID | PASS | P:912-926 sets value={q.id} |
| ivr uses IVR UUID | PASS | P:928-942 sets value={i.id} |
ivrs.list excludes current IVR (prevent self-loop) | PASS | P:208 setAllIvrs(list.filter((i) => i.id !== ivrId)) |
| Callback action → free-text phone input | PASS | P:887-895 returns a plain <Input> when action is callback |
| Hangup action → no destination picker | PASS | Inspector wraps picker in {selectedMenu.action_type !== "hangup" && …} at P:820-837, and DestinationPicker itself returns null for unknown/hangup paths at P:966 |
React Flow plumbing¶
| Requirement | Result | Citation |
|---|---|---|
ReactFlowProvider wraps the component | PASS | P:107-112 (exported component renders provider → inner) |
nodeTypes defined outside component | PASS | P:86-90 (module-level const) |
useNodesState / useEdgesState | PASS | P:160-161 |
onConnect adds edges via addEdge | PASS | P:328-331 setEdges((eds) => addEdge(conn, eds)) |
| Background, Controls, MiniMap rendered | PASS | P:608-610 |
D — Dialplan integration¶
Built QA-R2-multi (ext 7930) with options covering every tested action type (queue + ai_agent unavailable in this org — see warning below), published, grep'd /etc/asterisk/ext_astraprivate.conf.
; IVR Menu Options for QA-R2-multi
exten => 1,1,NoOp(IVR Option 1: ring hari)
exten => 1,n,Goto(org_mna9x47k__internal,1001,1)
exten => 2,1,NoOp(IVR Option 2: to reception)
exten => 2,n,Goto(org_mna9x47k__ivr,7001,1)
exten => 3,1,NoOp(IVR Option 3: voicemail)
exten => 3,n,VoiceMail(1005@org_mna9x47k_vm)
exten => 3,n,Hangup()
exten => 4,1,NoOp(IVR Option 4: callback)
exten => 4,n,Playback(callback-activated)
exten => 4,n,Set(CALLBACK_NUMBER=${CALLERID(num)})
exten => 4,n,Hangup()
exten => 9,1,NoOp(IVR Option 9: hangup)
exten => 9,n,Playback(goodbye)
exten => 9,n,Hangup()
| Check | Result | Evidence |
|---|---|---|
| Each digit has a Goto/exten handler | PASS | all 5 options rendered with distinct entry extens |
Extension (digit 1) uses user extension 1001 (not UUID) | PASS | Goto(org_mna9x47k__internal,1001,1) — the correct user ext resolved from UUID 20f84360-3b20-437c-8651-87cd7d36e859 |
Nested IVR (digit 2) uses target IVR's extension 7001 (not UUID) | PASS | Goto(org_mna9x47k__ivr,7001,1) — UUID 23d83ca7-b2ca-410f-9e99-615aaa90401f correctly translated to ext 7001 |
| Voicemail (digit 3) | PASS | VoiceMail(1005@org_mna9x47k_vm) |
Warning (D partial): this org has zero queues and zero routing_type=ai_agent users, so dialplan generation for those action types could not be exercised end-to-end. Code-path inspection via generator source indicates queue Goto would be Goto(org_mna9x47k__queue,<queue.number>,1) and ai_agent via the user's internal extension, matching the task's acceptance criteria — but this is not empirically verified on staging.
Cleanup verification¶
Before test run:
mysql> SELECT COUNT(*), GROUP_CONCAT(name) FROM ivrs WHERE org_id='7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79';
+----------+-----------------+
| cnt | names |
+----------+-----------------+
| 2 | reception,main 2|
+----------+-----------------+
Created during test run: QA-R2-publish, QA-R2-timeout0, QA-R2-negative, QA-R2-concur, QA-R2-multi, QA-R2-del (all prefixed QA-R2-).
After cleanup:
mysql> SELECT COUNT(*), GROUP_CONCAT(name) FROM ivrs WHERE org_id='7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79';
+----------+-----------------+
| 2 | reception,main 2|
+----------+-----------------+
mysql> SELECT m.* FROM ivr_menus m LEFT JOIN ivrs i ON m.ivr_id=i.id WHERE i.id IS NULL;
Empty set
ls /var/lib/asterisk/sounds/greetings/
greeting_ivr_23d83ca7-b2ca-410f-9e99-615aaa90401f.wav # only the pre-existing reception wav
Reception menu untouched — still contains the single pre-existing row (digit 4 → hangup, description="concurrent 4", created 2026-04-20 23:21 — pre-dates this QA round).
Also re-published reception afterwards so the live dialplan no longer references any deleted QA extensions:
Failures deep-dive¶
No hard failures. Three warnings:
W1 — Inspector's numeric || default coerces 0 back to non-zero (UI half-fix)¶
The API side of F2 is shipped (accepts and round-trips timeout: 0), but the builder UI still strips zero because the onChange handlers use || instead of ??:
// page.tsx:662
onChange={(e) => setTimeoutS(Number(e.target.value) || 10)}
// page.tsx:670
onChange={(e) => setMaxRetries(Number(e.target.value) || 3)}
Reproduction: in the inspector Timeout field, clear → type 0 → blur. Value becomes 10. User cannot configure "wait forever" from the UI, only via direct API.
Fix: switch to ?? or an explicit Number.isFinite check before falling back:
onChange={(e) => {
const v = e.target.value === "" ? 10 : Number(e.target.value);
setTimeoutS(Number.isFinite(v) ? v : 10);
}}
Severity: low — functionality is still reachable via API; UI just limits configurability. Worth fixing for consistency with the API contract.
W2 — No lower-bound validation on timeout / max_retries¶
POSTing {"timeout": -1, "max_retries": -1} is accepted by the API and writes WaitExten(-1) and GotoIf($[${IVR_RETRIES} < -1]?…) into the dialplan. Asterisk will treat -1 differently per function (WaitExten with negative will effectively be skipped/invalid; the GotoIf < -1 is always false so it never retries) — not a crash, but nonsensical config.
Suggested guard at server.js:4188 (create) and around :4207 (update):
if (typeof timeout === 'number' && timeout < 0) return res.status(400).json({ error: 'timeout must be ≥ 0' });
if (typeof max_retries === 'number' && max_retries < 0) return res.status(400).json({ error: 'max_retries must be ≥ 0' });
W3 — retries not shown on the Entry card¶
Task checklist says "Entry node shows name, extension, timeout, retries, direct-dial". EntryNode.tsx:32-41 shows name + extension + timeout + direct-dial but not retries. Inspector has it. Trivial one-line addition:
Severity: cosmetic.
Untested (environment limitation, not a failure)¶
- Queue action dialplan: no queues exist in AstraPrivate org. Code path unexercised end-to-end (only inspected).
- ai_agent action dialplan: no
routing_type=ai_agentusers in the org. Same caveat.
Recommend running this against an org that has at least one queue and one ai_agent user (e.g. a Grand-Estancia-style tenant) before promoting to prod.
Risks / observations¶
- UI/API contract drift on zero-values (W1) — harmless today but easy to forget about. Putting a fix in the same sprint as the F2 backend fix keeps the story clean.
- Negative numbers accepted (W2) — low risk in practice (nobody types -1 accidentally) but the generated dialplan is garbled. Cheap input validation closes it.
- No Queue/AI coverage in integration test (D) — the generator code looks correct, but "untested" ≠ "working". Schedule a one-off verification on a tenant that has those entities.
- React Flow edges are not persisted — the builder hydrates edges from
menusstate on every data change (page.tsx:249-326), so the user's manual rewiring is discarded. That appears intentional (menu-options are the source of truth), but worth documenting so nobody files it as a bug later. onConnectstill allows new edges to be drawn (P:328-331) even though they'll be overwritten by the next hydrate. Visually confusing — consider disabling the connect handle or converting it into a pass-through.
Recommended actions before prod promotion¶
- [ ] (W1) Fix
page.tsx:662and:670to use??/ explicit finite check sotimeout=0/max_retries=0round-trip through the UI. - [ ] (W2) Add lower-bound validation (
>= 0) fortimeoutandmax_retriesinPOST /ivrs(server.js:~4188) andPUT /ivrs/:id(server.js:~4207). - [ ] (W3 cosmetic) Show
{d.maxRetries} retrieson the Entry node card (EntryNode.tsx:37). - [ ] (coverage) Run a smoke test on an org with a queue + ai_agent user to verify those two action-type dialplan branches empirically.
- [ ] (optional polish) Disable the React Flow connect handle so users don't draw edges that get silently overwritten on next render.
Regression F1 / F2 / F3 verdict: all three ship-clean. The rebuilt React Flow UI has full feature parity with the old form-based builder modulo the three cosmetic warnings above. Ready for prod.