IVR Visual Builder — QA Report (2026-04-20)¶
Summary¶
- Total tests: 42
- Passed: 35
- Failed: 6
- Warnings: 1
- Overall verdict: BLOCKED
The CRUD, validation, TTS, greeting-file lifecycle, and authorization layers are solid and production-shaped. However, every call to POST /ivrs/:id/publish currently returns HTTP 500 because of a single code bug in the dialplan generator (IVR_RETRIES is not defined). Until that one-line fix lands, no IVR — including the two already sitting in the DB (reception ext 7001, main 2 ext 7002) — can be turned into a working Asterisk dialplan, which means inbound callers cannot reach any IVR menu. All the UI/API could ever do is fill the database; calls would never actually flow.
Two secondary issues were found: (a) timeout: 0 is silently coerced to 10 at create time (JS || truthiness bug), and (b) concurrent menu writes can deadlock in MySQL and return 500 to one of the callers. Neither blocks initial rollout on its own; both should be fixed before general availability.
Environment¶
- Branch:
feat/ivr-visual-builder - Staging commit SHA:
bed04d5(at/root/astradial-platform; running deployment at/opt/astrapbxis built from the same commit) - Test run timestamp: 2026-04-20T17:39:51Z → 17:40:05Z (UTC)
- Test org: AstraPrivate —
7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79(context prefixorg_mna9x47k_) - Isolation org for 4.2–4.5: Inky —
1fc05107-9675-475a-9635-9c320d55725b - Test harness: Bash + curl running on the VPS against
http://localhost:8000/api/v1, all requests authenticated withX-Internal-Key+?org_id=…. Isolation tests (4.x) used a forged JWT signed with the sameJWT_SECRETused by the running process. - Scratch fixtures created and cleaned up: one
QA Test Queue(number7950) was inserted for test 1.6 / 2.5 and removed at the end; 14QA Test IVR *rows were created, exercised, and deleted; a pre-test snapshot ofext_astraprivate.confwas compared byte-for-byte with the post-test file — the dialplan is unchanged from its pre-test state (diffempty).
Results by category¶
Symbols: ✅ PASS · ❌ FAIL · ⚠️ WARN
1. Functional / CRUD (7/7 pass)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 1.1 | GET /tts/voices returns ≥10 languages, each with non-empty voices | ✅ PASS | HTTP 200, 11 languages, every entry has a populated voices[] |
| 1.2 | POST /ivrs creates IVR (name + ext 7900 + language/voice) | ✅ PASS | HTTP 201, id 0f157b2b-… returned |
| 1.3 | GET /ivrs/:id returns the created IVR | ✅ PASS | HTTP 200, id field matches |
| 1.4 | GET /ivrs list includes the new IVR | ✅ PASS | HTTP 200, id present in response array |
| 1.5 | PUT /ivrs/:id updates name + description; re-fetch confirms | ✅ PASS | HTTP 200, follow-up GET shows new values |
| 1.6 | PUT /ivrs/:id/menu replaces options atomically (ext + queue + hangup) | ✅ PASS | HTTP 200, 3 options present with correct action_type + action_destination |
| 1.7 | DELETE /ivrs/:id returns 204; subsequent GET returns 404 | ✅ PASS | delete=204, follow-up GET=404 |
2. Input validation (12/12 pass)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 2.1 | POST /ivrs missing name → 400 | ✅ PASS | {"error":"name and extension required"} |
| 2.2 | POST /ivrs missing extension → 400 | ✅ PASS | {"error":"name and extension required"} |
| 2.3 | POST /ivrs ext collides with existing IVR (7001) → 409 + names it | ✅ PASS | {"error":"Extension 7001 is already used by IVR \"reception\""} |
| 2.4 | POST /ivrs ext matches user ext (1001 hari) → 409 | ✅ PASS | {"error":"Extension 1001 is already assigned to user \"hari\""} |
| 2.5 | POST /ivrs ext matches queue number (7950) → 409 | ✅ PASS | {"error":"Extension 7950 is already used by queue \"QA Test Queue\""} |
| 2.6 | PUT /ivrs/:id change extension to duplicate (7002) → 409 | ✅ PASS | {"error":"Extension 7002 is already used by IVR \"main 2\""} |
| 2.7 | PUT /ivrs/:id/menu with invalid digit "A" → 400 | ✅ PASS | {"error":"Invalid digit: A"} |
| 2.8 | PUT /ivrs/:id/menu with invalid action_type:"foo" → 400 | ✅ PASS | {"error":"Invalid action_type: foo"} |
| 2.9 | PUT /ivrs/:id/menu action_type:extension without destination → 400 | ✅ PASS | {"error":"action_destination required for action_type=extension"} |
| 2.10 | POST /ivrs/:id/generate-greeting with no text → 400 | ✅ PASS | {"error":"text is required"} |
| 2.11 | POST /tts/preview text > 500 chars → 400 | ✅ PASS | {"error":"preview text must be ≤ 500 characters"} |
| 2.12 | POST /tts/preview with no text → 400 | ✅ PASS | {"error":"text is required"} |
3. TTS / Audio (7/7 pass)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 3.1 | /tts/preview en-IN “Hello there” | ✅ PASS | HTTP 200, 15,276 bytes, first 4 bytes = RIFF (valid WAV header) |
| 3.2 | /tts/preview hi-IN “नमस्ते” | ✅ PASS | HTTP 200, 13,400 bytes |
| 3.3 | /tts/preview ta-IN “வணக்கம்” | ✅ PASS | HTTP 200, 15,672 bytes |
| 3.4 | /generate-greeting writes .wav on disk | ✅ PASS | HTTP 200, greeting_prompt=greeting_ivr_93b307d3-…, file present at /var/lib/asterisk/sounds/greetings/greeting_ivr_93b307d3-….wav (29,564 bytes) |
| 3.5 | /greeting-audio size matches on-disk file | ✅ PASS | HTTP 200, served Content-Length 29,564 == stat size 29,564 — byte-for-byte |
| 3.6 | Regenerate with hi-IN — mtime + DB row update | ✅ PASS | mtime 1776706786 → 1776706788; DB row greeting_language=hi-IN, greeting_voice=hi-IN-Wavenet-A |
| 3.7 | DELETE IVR removes .wav from disk | ✅ PASS | HTTP 204, file greeting_ivr_93b307d3-….wav absent after |
4. Authorization / isolation (6/6 pass)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 4.1 | GET /ivrs with no auth → 401 | ✅ PASS | {"error":"Authentication required"} |
| 4.2 | GET /ivrs/:id with Inky-scoped JWT for AstraPrivate’s IVR → 404 | ✅ PASS | {"error":"IVR not found"} — existence of id not leaked |
| 4.3 | PUT /ivrs/:id across orgs → 404 | ✅ PASS | {"error":"IVR not found"} |
| 4.4 | DELETE /ivrs/:id across orgs → 404 | ✅ PASS | {"error":"IVR not found"} |
| 4.5 | GET /greeting-audio with mismatched org_id → 404 | ✅ PASS | {"error":"IVR not found"} |
| 4.6 | POST /tts/preview with no auth → 401 | ✅ PASS | {"error":"Authentication required"} |
5. Dialplan integration (1 pass / 2 fail / 1 warn)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 5.1 | Publish IVR (ext 7910, 2 options) → dialplan has Background, WaitExten, Goto, Playback | ❌ FAIL | POST /publish returned HTTP 500: {"error":"IVR_RETRIES is not defined"}. Dialplan never written — 0 matching lines. |
| 5.2 | ai_agent menu option emits Goto to user’s ext | ⚠️ WARN | Skipped: no routing_type='ai_agent' users in AstraPrivate. Add one (or use a fixture org) to cover this path. |
| 5.3 | Nested IVR (option 1 of IVR-A → IVR-B) emits Goto(…_ivr,<B.ext>,1) | ❌ FAIL | Cannot be verified — publish 500 prevents dialplan generation. Logical code path in dialplanGenerator.js:641–643 looks correct on inspection. |
| 5.4 | DELETE + republish removes deleted IVR from dialplan | ✅ PASS | After DELETE and republishing from the sibling IVR, exten => 7910 count = 0 (empty context was written). Note: the republish itself also 500’d, but the generator appears to truncate the context before it throws, which is why the ext disappeared. Not reliable behaviour. |
6. Concurrency / race conditions (1 pass / 2 fail)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 6.1 | 5 concurrent POST /ivrs same ext (7920) — exactly one succeeds | ✅ PASS | Codes [409, 201, 409, 409, 409], final DB row count = 1 |
| 6.2 | 5 concurrent PUT /ivrs/:id/menu different option sets on same IVR | ❌ FAIL | Codes [200, 200, 500, 200, 200] — one caller got a MySQL deadlock (SQLState 40001, "Deadlock found when trying to get lock"). Final state is a single-option menu (digit 1), which is valid “last-write-wins”, but one client was served a hard 500 with no retry hint. |
| 6.3 | DELETE while /publish is in flight | ❌ FAIL | publish=500, delete=204, API still healthy afterwards. The 500 is the same IVR_RETRIES bug, not a concurrency issue. Cannot assess the real race until 5.1 is green. |
7. Edge cases (2 pass / 2 fail)¶
| # | Test | Status | Evidence |
|---|---|---|---|
| 7.1 | Create IVR with timeout=0 | ❌ FAIL | HTTP 201 but DB row has timeout=10. server.js:4188 uses timeout: timeout || 10 which coerces the integer 0 to the default. The UI cannot express “wait forever”. |
| 7.2 | Menu with 12 options (0–9, *, #) | ✅ PASS | HTTP 200, all 12 options persisted. |
| 7.3 | Very long text — preview at 499 chars + generate at ~1,500 chars | ✅ PASS | Preview: HTTP 200, non-trivial WAV returned. Generate-greeting: HTTP 200, WAV written. Note: /generate-greeting has no length cap, whereas preview is capped at 500 — see risks section. |
| 7.4 | status=inactive IVR not emitted by publish | ❌ FAIL | Could not be verified — publish 500 means before_count=0 (IVR never appeared in the first place). Code inspection of dialplanGenerator.js:549 confirms if (ivr.status === 'active') guard is present and correct, so this test will pass once 5.1 is fixed. |
Failures deep-dive¶
F1 — IVR_RETRIES is not defined on every /publish (tests 5.1, 5.3, 6.3, 7.4 all blocked)¶
Severity: CRITICAL — this single bug blocks the entire IVR feature from ever being used by a live call.
Exact curl:
curl -X POST 'http://localhost:8000/api/v1/ivrs/23d83ca7-b2ca-410f-9e99-615aaa90401f/publish?org_id=7f3d2fd5-347e-4cc2-a2d0-a9a5e0f78f79' \
-H "X-Internal-Key: $INT_KEY"
Response: HTTP 500 · {"error":"IVR_RETRIES is not defined"}
Server stack trace (from pm2 logs astrapbx):
Error generating dialplan configuration: ReferenceError: IVR_RETRIES is not defined
at DialplanGenerator.generateIvrExtension (/opt/astrapbx/src/services/asterisk/dialplanGenerator.js:577:52)
at DialplanGenerator.generateIvrContext (/opt/astrapbx/src/services/asterisk/dialplanGenerator.js:548:14)
Root cause: In api/src/services/asterisk/dialplanGenerator.js around line 577, the IVR timeout-retry handling uses JavaScript template literals to write the dialplan, but the ${IVR_RETRIES} tokens are intended as Asterisk runtime variables — they must reach the .conf file literally as ${IVR_RETRIES}, not be interpolated by JS. Currently they are JS-interpolated, which throws because IVR_RETRIES is not a JS binding.
Three offending lines (577, 588, 589):
extension += `exten => t,n,Set(IVR_RETRIES=$[${IVR_RETRIES} + 1])\n`;
extension += `exten => t,n,GotoIf($[${IVR_RETRIES} < ${ivr.max_retries || 3}]?${ivr.extension},start)\n`;
// and similarly for exten => i,…
extension += `exten => i,n,Set(IVR_RETRIES=$[${IVR_RETRIES} + 1])\n`;
extension += `exten => i,n,GotoIf($[${IVR_RETRIES} < ${ivr.max_retries || 3}]?retry:maxretries)\n`;
Suggested fix — escape the $ so JS doesn’t interpolate (compare with line 562 which correctly escapes \${EXTEN}):
// dialplanGenerator.js lines 577, 588–589
extension += `exten => t,n,Set(IVR_RETRIES=$[\${IVR_RETRIES} + 1])\n`;
extension += `exten => t,n,GotoIf($[\${IVR_RETRIES} < ${ivr.max_retries || 3}]?${ivr.extension},start)\n`;
// …
extension += `exten => i,n,Set(IVR_RETRIES=$[\${IVR_RETRIES} + 1])\n`;
extension += `exten => i,n,GotoIf($[\${IVR_RETRIES} < ${ivr.max_retries || 3}]?retry:maxretries)\n`;
After this fix, re-run tests 5.1, 5.3, 6.3, 7.4.
F2 — timeout: 0 silently coerced to 10 (test 7.1)¶
Severity: MINOR — unexpected behaviour, but the UI today doesn’t allow 0 so the customer impact is zero until someone tries to use WaitExten(0) (wait-forever) via API.
Exact curl:
curl -X POST 'http://localhost:8000/api/v1/ivrs?org_id=...' \
-H "X-Internal-Key: $INT_KEY" -H "Content-Type: application/json" \
-d '{"name":"QA Test IVR timeout0","extension":"7930","timeout":0}'
Response: HTTP 201 — but the saved row shows "timeout":10, not 0.
Root cause: api/src/server.js:4188 uses short-circuit ||:
0 || 10 === 10, the zero is lost. Suggested fix:
(or use nullish coalescing:timeout ?? 10). Same pattern should be reviewed at server.js:3797 and 3956 — they were not tested here but use the identical idiom. The renderer at dialplanGenerator.js:573 has the same flaw (ivr.timeout || 10), so even if the DB did hold 0, the dialplan would still emit WaitExten(10). Fix both sides.
F3 — Concurrent menu PUT deadlock (test 6.2)¶
Severity: MEDIUM — can bite real users if two admins edit the same IVR simultaneously, or if the editor retries on a slow network.
Exact curl (one of five fired in parallel):
curl -X PUT 'http://localhost:8000/api/v1/ivrs/<id>/menu?org_id=...' \
-H "X-Internal-Key: $INT_KEY" -H "Content-Type: application/json" \
-d '{"options":[{"digit":"3","action_type":"extension","action_destination":"1003"}]}'
Response (1 of 5): HTTP 500 · {"error":"(conn:28844, no: 1213, SQLState: 40001) Deadlock found when trying to get lock; try restarting transaction"}
Server log:
PUT /ivrs/:id/menu error: Deadlock found when trying to get lock; try restarting transaction
sql: DELETE FROM `ivr_menus` WHERE `ivr_id` = '69500a7d-…'
Root cause: server.js:4292 wraps the replace-options logic in a Sequelize transaction that does DELETE FROM ivr_menus WHERE ivr_id = ? followed by N INSERT statements. Under concurrent writes, InnoDB’s gap locks on the ivr_id index cause a deadlock. The final DB state is still consistent (one write wins), but one caller gets an opaque 500.
Suggested fixes (pick one): 1. Retry on deadlock in the handler — catch SequelizeDatabaseError with SQLState 40001 and re-run the transaction 1–2 times before surfacing. Sequelize has a retry option that does this. 2. Serialize via an advisory lock (e.g. SELECT GET_LOCK(CONCAT('ivr_menu_', ivr_id), 5)) at the top of the transaction so concurrent editors block rather than deadlock. 3. Return 409 Conflict with a useful message instead of 500 — lets the editor UI retry gracefully.
F4 — Test 5.4 passed “by accident”¶
Test 5.4 (DELETE + republish removes the IVR from the dialplan) was marked PASS because after the delete the extension count was 0 in the dialplan context. However, the republish itself returned 500 (same F1 bug), and the reason the extension was absent is that the dialplan generator seems to truncate-then-fail mid-write on a fresh invocation. This is not a valid validation — once F1 is fixed, re-run 5.4 to confirm the intended behaviour.
Risks / Observations not covered by tests¶
- Two pre-existing IVRs (
reception7001,main 27002) are in the DB but NOT in the dialplan. Because every publish has been failing with F1, these IVRs have never actually been deployed to Asterisk. If any call tries to Goto them today it falls through to the default no-IVR branch ([org_mna9x47k__ivr]is an empty context). This won’t show up until someone tests an inbound call. After F1 is fixed, schedule a pass of/publishfor every active IVR to reconcile. - No length cap on
/generate-greeting. Preview is capped at 500 chars; the full-generate endpoint is uncapped. Google Cloud TTS bills per character and will gladly accept requests with tens of thousands of characters. Consider a server-side cap (e.g. 5,000 chars — longer than any reasonable greeting) and a monthly usage ceiling per org. /publishis a global side-effect on the whole org. It rebuildsext_astraprivate.confend-to-end and reloads Asterisk. Publishing one IVR regenerates every IVR, user, queue, trunk. This was flagged by the advisor before the test run and confirmed by observing the generated file. Implication: any change the builder UI triggers (even touching an inactive IVR) is effectively “publish everything”. This is fine for small orgs, less fine at scale — and it explicitly prevents publishing to a staging extension without also re-deploying all other tenants’ work-in-progress. Consider per-IVR include files in a future iteration.status=inactiveis honoured by the generator (dialplanGenerator.js:549), which is great. But the API has no mutex between status flips and publishes — a user could toggle an IVR to inactive, and if their publish hasn’t fired yet, the dialplan still has the old entry. Not a bug, but worth documenting in the UI copy (“Click Publish to take this IVR offline”).- Internal-key shortcut works everywhere. The
X-Internal-Key+?org_id=pattern bypasses all JWT checks and grants full org-scoped access. This is by design for server-to-server traffic (editor, workflow engine), but if the key ever leaks it is as powerful as an org admin JWT for every org. Consider rotatingINTERNAL_API_KEYafter GA and adding an IP allowlist. - Cross-org isolation works, but by returning 404 (not 403). Good: hides existence. Potential audit-log opportunity: log these 404-on-ownership-mismatch events as “authorization failure” so suspicious probing is visible.
/tts/voicesis served without hitting a DB or an external API. It’s a static in-memory list (SUPPORTED_TTS_VOICES). If Google adds a new language/voice, someone has to remember to update that constant. Low risk; call out in the dev runbook.- Greeting audio files have no org-level subdirectory. All greetings live together in
/var/lib/asterisk/sounds/greetings/. The filename is prefixed with the IVR UUID, so there is no collision risk — but anlsin that directory leaks the IVR UUIDs of every tenant to any engineer with shell access. Consider…/greetings/<org_id>/ivr_<ivr_id>.wavif tenant hygiene matters.
Recommended actions before rollout to prod¶
- [BLOCKER] Fix F1 — escape
${IVR_RETRIES}indialplanGenerator.js(lines ~577, 588–589). One-line code change, ~15-minute fix. Re-run tests 5.1, 5.3, 5.4, 6.3, 7.4 afterwards — all should go green. - [BLOCKER] Publish every existing IVR in every org after F1 lands, so the two orphaned-in-DB IVRs (
reception,main 2) and any others actually become reachable. A one-shot script that loops overSELECT id, org_id FROM ivrs WHERE status='active'and fires/publishwill do it. - [HIGH] Fix F3 — retry on InnoDB deadlock in
PUT /ivrs/:id/menu. Otherwise two admins using the editor together will occasionally see a confusing 500. - [MEDIUM] Fix F2 — preserve
timeout: 0. Use??instead of||in bothserver.js(create/update) anddialplanGenerator.js(render). Apply the same fix to the other twotimeout * 1000call sites atserver.js:3797and3956for consistency. - [MEDIUM] Add a length cap to
/generate-greeting(e.g. 5,000 chars) to protect against runaway Google TTS spend. - [LOW] Add an
ai_agentuser to an internal test org so CI can cover the IVR-menu → AI agent path (test 5.2). - [LOW] Add a migration-guard test: a startup self-check that publishes a throw-away
health-checkIVR into atest_*org, greps the generated .conf for the expected directives, and fails the boot if a regression like F1 reappears. Would have caught this bug before it reached staging. - [LOW] Emit “authorization failure” audit-log events for cross-org 404s on IVR read/write (tests 4.2–4.5).
Prepared by: QA harness run on staging (94.136.188.221) against commit bed04d5 of feat/ivr-visual-builder, 2026-04-20. Raw TSV results file: /tmp/qa-results.tsv on the staging VPS. Harness script: /tmp/qa-ivr-runner.sh on the staging VPS (also at /tmp/qa-ivr-runner.sh locally).