From 1288a960d4ca3d2f84a88651743e3dd2ca011481 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 15:50:03 +0200 Subject: [PATCH] Phase 3 sub-commit 4: bridge-integration test (HTTP/SSE round-trip + crash-recover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/todo/Makefile | 9 +- examples/todo/tests/.gitignore | 1 + examples/todo/tests/integration.sh | 160 +++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 examples/todo/tests/.gitignore create mode 100755 examples/todo/tests/integration.sh diff --git a/examples/todo/Makefile b/examples/todo/Makefile index f5cc2fc..ab32035 100644 --- a/examples/todo/Makefile +++ b/examples/todo/Makefile @@ -36,7 +36,12 @@ doctor-connect: ## Run bridge:doctor with backend connectivity probe clean: ## Remove build artefacts rm -rf $(BUILD_DIR) +.PHONY: integration +integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover) + ./tests/integration.sh + .PHONY: quality -quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint - cd ../php && composer quality +quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration + cd ../../framework/php && composer quality cmake --build $(BUILD_DIR) --target all_qmllint + ./tests/integration.sh diff --git a/examples/todo/tests/.gitignore b/examples/todo/tests/.gitignore new file mode 100644 index 0000000..6e4328c --- /dev/null +++ b/examples/todo/tests/.gitignore @@ -0,0 +1 @@ +var/ diff --git a/examples/todo/tests/integration.sh b/examples/todo/tests/integration.sh new file mode 100755 index 0000000..31bb049 --- /dev/null +++ b/examples/todo/tests/integration.sh @@ -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" < "$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."