#!/usr/bin/env bash # Bridge-integration test for the todo example. Drives FrankenPHP # against an isolated SQLite database, exercises the HTTP + SSE # round-trip, asserts the §4 envelope shape (correlationKey echo, # dual publish), then kills + restarts the backend to verify # crash-and-recover behaviour described in PLAN.md §13 Phase 3. # # Designed to be safe against an existing dev instance: uses port # 8767 (one above dev) and a temporary database under tests/var/. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" SYMFONY_DIR="$APP_DIR/symfony" TEST_VAR="$SCRIPT_DIR/var" PORT=8767 TOKEN=integration-token JWT="dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci" : "${FRANKENPHP:=frankenphp}" if ! command -v "$FRANKENPHP" >/dev/null 2>&1; then echo "frankenphp not on PATH (set FRANKENPHP env var)" >&2 exit 1 fi mkdir -p "$TEST_VAR" DB_FILE="$TEST_VAR/integration.sqlite" rm -f "$DB_FILE" # Generate a per-test Caddyfile bound to a non-conflicting port. CADDYFILE="$TEST_VAR/Caddyfile" cat > "$CADDYFILE" < "$TEST_VAR/frankenphp.log" 2>&1 & BACKEND_PID=$! # Wait for /healthz for _ in $(seq 1 50); do curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 && return 0 kill -0 "$BACKEND_PID" 2>/dev/null || { echo "frankenphp died early" >&2; cat "$TEST_VAR/frankenphp.log" >&2; exit 1; } sleep 0.2 done echo "frankenphp didn't respond after 10s" >&2 cat "$TEST_VAR/frankenphp.log" >&2 exit 1 } stop_backend() { [ -z "${BACKEND_PID:-}" ] && return 0 kill -TERM "$BACKEND_PID" 2>/dev/null || true for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$BACKEND_PID" 2>/dev/null || break sleep 0.2 done kill -KILL "$BACKEND_PID" 2>/dev/null || true wait "$BACKEND_PID" 2>/dev/null || true BACKEND_PID="" } cleanup() { trap - EXIT INT TERM stop_backend } trap cleanup EXIT INT TERM step() { echo "→ $*"; } fail() { echo "✗ FAIL: $*" >&2; exit 1; } # ── Setup ────────────────────────────────────────────────────────────── step "Migrate schema into $DB_FILE" ( cd "$SYMFONY_DIR" && DATABASE_URL="sqlite:///$DB_FILE" APP_ENV=dev APP_DEBUG=0 APP_SECRET=int \ bin/console doctrine:migrations:migrate -n >/dev/null ) step "Boot FrankenPHP on :$PORT" start_backend # ── HTTP round-trip ──────────────────────────────────────────────────── step "POST /api/todos" RESP="$(curl -fsS -m 3 -X POST "http://127.0.0.1:$PORT/api/todos" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: it-key-1' \ -d '{"title":"first","done":false}')" echo "$RESP" | grep -q '"title":"first"' || fail "POST response missing title" ID="$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" [ -n "$ID" ] || fail "POST response missing id" step "GET /api/todos returns the row" LIST="$(curl -fsS -m 3 "http://127.0.0.1:$PORT/api/todos" -H "Authorization: Bearer $TOKEN")" echo "$LIST" | grep -q "$ID" || fail "GET didn't include $ID" # ── Mercure dual-publish + correlationKey echo ───────────────────────── step "Open SSE, POST during it, verify envelope" SSE_OUT="$TEST_VAR/sse.txt" ( curl -sSN -m 4 "http://127.0.0.1:$PORT/.well-known/mercure?topic=app%3A%2F%2Fmodel%2Ftodo" > "$SSE_OUT" 2>/dev/null & SP=$! sleep 0.5 curl -fsS -m 3 -X POST "http://127.0.0.1:$PORT/api/todos" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H 'Idempotency-Key: it-key-2' \ -d '{"title":"second","done":false}' > /dev/null wait $SP 2>/dev/null || true ) grep -q '"op":"upsert"' "$SSE_OUT" || fail "SSE missing op:upsert" grep -q '"correlationKey":"it-key-2"' "$SSE_OUT" || fail "SSE missing correlationKey echo" grep -q '"title":"second"' "$SSE_OUT" || fail "SSE missing payload" step "Both topics receive the envelope" PUB_COUNT=$(grep -c 'Update published' "$TEST_VAR/frankenphp.log" || true) [ "$PUB_COUNT" -ge 2 ] || fail "expected ≥2 Update published entries (collection + entity), got $PUB_COUNT" # ── Crash and recover ────────────────────────────────────────────────── step "Kill the backend" stop_backend sleep 0.3 curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 \ && fail "backend should be down but /healthz still answered" step "Restart and verify GET still works" start_backend RESP2="$(curl -fsS -m 3 "http://127.0.0.1:$PORT/api/todos" -H "Authorization: Bearer $TOKEN")" echo "$RESP2" | grep -q "$ID" || fail "after restart GET missing original todo" echo "$RESP2" | grep -q '"second"' || fail "after restart GET missing second todo" step "All bridge-integration assertions passed."