Phase 3 sub-commit 4: bridge-integration test (HTTP/SSE round-trip + crash-recover)
examples/todo/tests/integration.sh boots FrankenPHP against the example
app on an isolated port (8767) and database (tests/var/integration.sqlite),
then asserts:
- POST /api/todos creates a row, GET returns it.
- SSE stream on app://model/todo carries the §4 envelope with op,
correlationKey echoed from Idempotency-Key, and the JSON payload.
- The backend log shows ≥2 "Update published" lines per change
(collection topic + entity topic — dual publish per ModelPublisher).
- Killing FrankenPHP makes /healthz unreachable; restarting it
restores GET access without losing data.
Wired into make quality alongside the existing PHPStan / cs-fixer /
PHPUnit / qmllint checks. The script is self-contained — runs against
the example without disturbing a developer's `make dev` instance.
qmltestrunner integration deferred: out-of-the-box runner can't see
PhpQml.Bridge because the framework module is statically linked into
the host binary. A proper QML test target would need a custom CMake
executable that links the module + uses QtQuickTest's quick_test_main.
Phase 3.x or Phase 5 polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
examples/todo/tests/.gitignore
vendored
Normal file
1
examples/todo/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
var/
|
||||
160
examples/todo/tests/integration.sh
Executable file
160
examples/todo/tests/integration.sh
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/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" <<EOF
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
frankenphp
|
||||
order php_server before respond
|
||||
order mercure after encode
|
||||
}
|
||||
|
||||
http://127.0.0.1:$PORT {
|
||||
root * $SYMFONY_DIR/public/
|
||||
encode gzip
|
||||
mercure {
|
||||
transport local
|
||||
publisher_jwt $JWT
|
||||
subscriber_jwt $JWT
|
||||
anonymous
|
||||
publish_origins *
|
||||
cors_origins *
|
||||
}
|
||||
php_server
|
||||
log {
|
||||
output stderr
|
||||
format console
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
start_backend() {
|
||||
DATABASE_URL="sqlite:///$DB_FILE" \
|
||||
BRIDGE_TOKEN="$TOKEN" \
|
||||
MERCURE_URL="http://127.0.0.1:$PORT/.well-known/mercure" \
|
||||
MERCURE_PUBLIC_URL="http://127.0.0.1:$PORT/.well-known/mercure" \
|
||||
MERCURE_JWT_SECRET="$JWT" \
|
||||
MERCURE_PUBLISHER_JWT_KEY="$JWT" \
|
||||
MERCURE_SUBSCRIBER_JWT_KEY="$JWT" \
|
||||
APP_ENV=dev APP_DEBUG=0 APP_SECRET=integration-secret \
|
||||
"$FRANKENPHP" run --config "$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."
|
||||
Reference in New Issue
Block a user