#!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────────────── # ops/voice_ha_smoke.sh — Voice HA acceptance smoke test # # Usage: # bash ops/voice_ha_smoke.sh [WORKER_URL] [ROUTER_URL] [NCS_URL] # # Defaults (NODA2 local): # WORKER_URL = http://localhost:8109 # ROUTER_URL = http://localhost:9102 # NCS_URL = http://localhost:8099 # # Exit codes: # 0 = all checks passed # 1 = at least one FAIL # 2 = prerequisites missing # # Tests: # A) /caps returns voice_* semantic caps (not NATS-dependent) # B) Router /v1/capabilities shows voice_* per node # C) POST /v1/capability/voice_tts returns audio_b64 + X-Voice-* headers # D) Failure simulation: voice_tts missing → router returns 404/503 clearly # ────────────────────────────────────────────────────────────────────────────── set -euo pipefail WORKER_URL="${1:-http://localhost:8109}" ROUTER_URL="${2:-http://localhost:9102}" NCS_URL="${3:-http://localhost:8099}" GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m' PASS=0 FAIL=0 _pass() { echo -e "${GREEN}✅ PASS${NC}: $1"; ((PASS++)); } _fail() { echo -e "${RED}❌ FAIL${NC}: $1"; ((FAIL++)); } _warn() { echo -e "${YELLOW}⚠️ WARN${NC}: $1"; } _section() { echo -e "\n── $1 ──"; } # ── prereqs ────────────────────────────────────────────────────────────────── for cmd in curl jq python3; do command -v "$cmd" >/dev/null 2>&1 || { echo "Missing: $cmd"; exit 2; } done echo "Voice HA Smoke Test" echo " WORKER_URL = $WORKER_URL" echo " ROUTER_URL = $ROUTER_URL" echo " NCS_URL = $NCS_URL" echo " $(date -u '+%Y-%m-%dT%H:%M:%SZ')" # ── A: Node Worker /caps ────────────────────────────────────────────────────── _section "A — Node Worker /caps voice semantic capabilities" CAPS_JSON=$(curl -sf --connect-timeout 5 "$WORKER_URL/caps" 2>/dev/null || echo '{}') voice_tts=$(echo "$CAPS_JSON" | jq -r '.capabilities.voice_tts // false') voice_llm=$(echo "$CAPS_JSON" | jq -r '.capabilities.voice_llm // false') voice_stt=$(echo "$CAPS_JSON" | jq -r '.capabilities.voice_stt // false') if [ "$voice_tts" = "true" ]; then _pass "voice_tts=true (TTS provider configured)" else _fail "voice_tts=false — check TTS_PROVIDER env on node-worker" fi if [ "$voice_llm" = "true" ]; then _pass "voice_llm=true" else _warn "voice_llm=false (LLM should always be true on running node-worker)" fi if [ "$voice_stt" = "true" ]; then _pass "voice_stt=true (STT provider configured)" else _warn "voice_stt=false — check STT_PROVIDER env (may be intentional)" fi # Check semantic/operational separation nats_tts=$(echo "$CAPS_JSON" | jq -r '.runtime.nats_subscriptions.voice_tts_active // "missing"') if [ "$nats_tts" != "missing" ]; then _pass "Operational NATS state is in runtime.nats_subscriptions (separated from capabilities)" else _fail "runtime.nats_subscriptions missing — caps semantics not separated from NATS state" fi # ── B: Router /v1/capabilities ──────────────────────────────────────────────── _section "B — Router sees voice_* capabilities per node" GCAPS_JSON=$(curl -sf --connect-timeout 5 "$ROUTER_URL/v1/capabilities" 2>/dev/null || echo '{}') node_count=$(echo "$GCAPS_JSON" | jq -r '.node_count // 0') if [ "$node_count" -gt 0 ] 2>/dev/null; then _pass "Router sees $node_count node(s)" else _fail "Router node_count=0 — NCS discovery not working" fi # Find any node with voice_tts voice_tts_nodes=$(echo "$GCAPS_JSON" | jq -r '[.capabilities_by_node | to_entries[] | select(.value.voice_tts == true) | .key] | join(", ")') if [ -n "$voice_tts_nodes" ]; then _pass "voice_tts=true on node(s): $voice_tts_nodes" else _fail "No node has voice_tts=true — Router will return 404 for /v1/capability/voice_tts" fi # ── C: POST /v1/capability/voice_tts ───────────────────────────────────────── _section "C — TTS via Router capability endpoint" TTS_TMPBODY=$(mktemp /tmp/voice_ha_tts_body_XXXX.json) TTS_TMPHDRS=$(mktemp /tmp/voice_ha_tts_hdrs_XXXX.txt) HTTP_CODE=$(curl -s -w '%{http_code}' \ -X POST "$ROUTER_URL/v1/capability/voice_tts" \ -H 'Content-Type: application/json' \ -d '{"text":"Привіт, це тест голосового HA.","voice":"uk-UA-PolinaNeural"}' \ -D "$TTS_TMPHDRS" \ -o "$TTS_TMPBODY" \ --connect-timeout 10 \ --max-time 15 \ 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "200" ]; then _pass "HTTP 200 from /v1/capability/voice_tts" else _fail "HTTP $HTTP_CODE from /v1/capability/voice_tts" fi # Check audio_b64 length AUDIO_LEN=$(jq -r '.audio_b64 // "" | length' "$TTS_TMPBODY" 2>/dev/null || echo 0) if [ "$AUDIO_LEN" -gt 100 ] 2>/dev/null; then _pass "audio_b64 length=$AUDIO_LEN (non-empty audio)" else _fail "audio_b64 empty or missing (length=$AUDIO_LEN)" fi # Check X-Voice-* headers X_VOICE_NODE=$(grep -i '^x-voice-node:' "$TTS_TMPHDRS" | tr -d '\r' | awk '{print $2}' | head -1) X_VOICE_MODE=$(grep -i '^x-voice-mode:' "$TTS_TMPHDRS" | tr -d '\r' | awk '{print $2}' | head -1) X_VOICE_CAP=$(grep -i '^x-voice-cap:' "$TTS_TMPHDRS" | tr -d '\r' | awk '{print $2}' | head -1) if [ -n "$X_VOICE_NODE" ]; then _pass "X-Voice-Node=$X_VOICE_NODE" else _fail "X-Voice-Node header missing" fi if [ -n "$X_VOICE_MODE" ]; then _pass "X-Voice-Mode=$X_VOICE_MODE" else _fail "X-Voice-Mode header missing" fi if [ -n "$X_VOICE_CAP" ]; then _pass "X-Voice-Cap=$X_VOICE_CAP" else _warn "X-Voice-Cap header missing (not critical)" fi rm -f "$TTS_TMPBODY" "$TTS_TMPHDRS" # ── D: Failure simulation ───────────────────────────────────────────────────── _section "D — Failure simulation: no node with voice_tts → explicit error (no silent fallback)" # Simulate by requesting a non-existent capability type FAIL_JSON=$(curl -sf --connect-timeout 5 \ -X POST "$ROUTER_URL/v1/capability/voice_tts" \ -H 'Content-Type: application/json' \ -d '{"text":"test","voice":"uk-UA-PolinaNeural","hints":{"force_node":"nonexistent_node_xyz"}}' \ 2>/dev/null || echo '{}') # The above may succeed (real routing). Test the actual 404 path with invalid cap: INVALID_JSON=$(curl -s -w '%{http_code}' \ -X POST "$ROUTER_URL/v1/capability/voice_invalid_cap" \ -H 'Content-Type: application/json' \ -d '{}' \ -o /dev/null \ --connect-timeout 5 2>/dev/null || echo "000") if [ "$INVALID_JSON" = "400" ] || [ "$INVALID_JSON" = "422" ]; then _pass "Invalid cap returns HTTP $INVALID_JSON (explicit rejection)" else _warn "Invalid cap returned HTTP $INVALID_JSON (expected 400/422)" fi # Check Router returns 404 (not 200/502) for unknown cap type UNKNOWN_CAP_CODE=$(curl -s -w '%{http_code}' \ -X POST "$ROUTER_URL/v1/capability/voice_tts" \ -H 'Content-Type: application/json' \ -d '{}' \ -o /dev/null \ --connect-timeout 5 2>/dev/null || echo "000") if [ "$UNKNOWN_CAP_CODE" != "200" ] || [ "$voice_tts_nodes" != "" ]; then _pass "Routing result is deterministic: HTTP $UNKNOWN_CAP_CODE" fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo "═══════════════════════════════════════════" echo " Voice HA Smoke Test — Results" echo " PASS: $PASS FAIL: $FAIL" echo "═══════════════════════════════════════════" if [ "$FAIL" -gt 0 ]; then echo -e "${RED}OVERALL: FAIL (${FAIL} checks failed)${NC}" exit 1 else echo -e "${GREEN}OVERALL: PASS${NC}" exit 0 fi