Files
ehoandClaude Haiku 4.5 5f22156e6d feat(ownership): add take-ownership preflight endpoint
Enables dry-run checking before committing to case adoption. The preflight
shares the same side-effect-free checks (steps 1–3) as the real take-ownership
handler, so it cannot drift from what will actually succeed. Returns the same
status codes and error shapes as the real endpoint (200 with wouldSucceed:true,
or 409/404/422 if it would fail).

Portal renders a "Vooraf controleren" button for legacy cases, surfaced through
the existing actions block pattern. Confirmed in smoke.sh with two cases: one
where preflight predicts success (and writes nothing), one where it predicts
a named invariant failure (matching what the real call reproduces).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 09:15:18 +02:00

175 lines
9.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Smoke-tests the strangler-fig-demo stack against the acceptance criteria in
# the design notes (§13). Run against a FRESH `docker compose up` - it
# depends on the seed data being untouched (12 legacy rows 1001-1012, 5 owned
# REG-2026-0001..0005 at fixed ids). Exits non-zero on the first failure.
set -euo pipefail
BASE="${SMOKE_BASE_URL:-http://localhost:8080}"
FAILURES=0
pass() { echo " OK $1"; }
fail() { echo " FAIL $1"; FAILURES=$((FAILURES + 1)); }
check_status() {
local desc="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then pass "$desc ($actual)"; else fail "$desc (expected $expected, got $actual)"; fi
}
json_field() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d$1)"; }
echo "== Infrastructure =="
STATUS=$(docker compose ps --format json 2>/dev/null | python3 -c "
import sys, json
ok = True
for line in sys.stdin:
line = line.strip()
if not line:
continue
d = json.loads(line)
svc = d.get('Service')
state = d.get('State')
health = d.get('Health', '')
if state != 'running':
print(f'{svc}: state={state}'); ok = False
if health and health != 'healthy':
print(f'{svc}: health={health}'); ok = False
print('ALL_OK' if ok else 'SOME_FAILED')
")
if echo "$STATUS" | grep -q ALL_OK; then pass "all 10 containers running/healthy"; else fail "container status: $STATUS"; fi
if curl -sS --max-time 2 http://localhost:8081 >/dev/null 2>&1; then
fail "legacy-backend must NOT be reachable from the host (port 8081 responded)"
else
pass "legacy-backend not reachable from host"
fi
if curl -sS --max-time 2 http://localhost:8082 >/dev/null 2>&1; then
fail "case-framework must NOT be reachable from the host (port 8082 responded)"
else
pass "case-framework not reachable from host"
fi
echo "== Portal frontend (Session 2) =="
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/")
check_status "GET /portal/ serves the Angular app" "200" "$HTTP"
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/legacy/1001")
check_status "deep link /portal/legacy/1001 falls back to index.html" "200" "$HTTP"
# Regression check: nginx's implicit redirect construction uses its own
# internal `listen` port, not the host's published port - a naive
# `return 301 /portal/` silently drops :8080 from the Location header.
BARE_REDIRECT_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal")
check_status "GET /portal (no slash) redirects" "301" "$BARE_REDIRECT_STATUS"
FOLLOWED=$(curl -sSL -o /dev/null -w '%{http_code}' "$BASE/portal")
check_status "following that redirect lands on a 200, on the same host:port" "200" "$FOLLOWED"
echo "== Seam A: unified read =="
WORKLIST=$(curl -sS "$BASE/api/worklist")
TOTAL=$(echo "$WORKLIST" | json_field "['totalCount']")
if [ "$TOTAL" = "17" ]; then pass "GET /api/worklist returns 17 items"; else fail "expected 17 items, got $TOTAL"; fi
DETAIL_1001=$(curl -sS "$BASE/api/worklist/legacy/1001")
SEAM_AANVRAGER=$(echo "$DETAIL_1001" | json_field "['seams']['aanvrager']")
check_status "A-1001 seam inspector names legacy-backend" "legacy-backend" "$SEAM_AANVRAGER"
echo "== Write path 1: redirect (seam C) =="
MODE=$(echo "$DETAIL_1001" | json_field "['actions']['recordAssessment']['mode']")
check_status "A-1001 recordAssessment action is a redirect" "redirect" "$MODE"
echo "== Write path 2: write-through (seam B) =="
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$BASE/api/worklist/legacy/1001/details" \
-H "Content-Type: application/json" \
-d '{"surname":"de Vries","initials":"A.","address":{"street":"Kerkweg","number":"12","postalCode":"3512JK","city":"Utrecht"},"email":"anna.devries@example.nl","phone":"+31612345678","preferredChannel":"Post"}')
check_status "valid write-through details edit on A-1001" "204" "$HTTP"
ERRORS=$(curl -sS -X PUT "$BASE/api/worklist/legacy/1001/details" \
-H "Content-Type: application/json" \
-d '{"surname":"","initials":"A.","address":{"street":"Kerkweg","number":"","postalCode":"12AB","city":"Utrecht"},"email":null,"phone":null,"preferredChannel":"Post"}')
ERROR_COUNT=$(echo "$ERRORS" | json_field "['errors'].__len__()" 2>/dev/null || echo "$ERRORS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['errors']))")
check_status "3-field-invalid write-through returns 3 mapped errors" "3" "$ERROR_COUNT"
echo "== Write path 3 preflight: shadow check before take ownership =="
PREFLIGHT_1004=$(curl -sS -o /tmp/preflight_1004.json -w '%{http_code}' "$BASE/api/worklist/legacy/1004/take-ownership/preflight")
check_status "preflight predicts A-1004 would adopt cleanly" "200" "$PREFLIGHT_1004"
WOULD_SUCCEED=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1004.json'))['wouldSucceed'])")
check_status "preflight body reports wouldSucceed" "True" "$WOULD_SUCCEED"
SEAM_1004_AFTER_PREFLIGHT=$(curl -sS "$BASE/api/worklist/legacy/1004" | json_field "['seams']['aanvrager']")
check_status "preflight on A-1004 wrote nothing (still legacy-backend)" "legacy-backend" "$SEAM_1004_AFTER_PREFLIGHT"
PREFLIGHT_1005=$(curl -sS -o /tmp/preflight_1005.json -w '%{http_code}' "$BASE/api/worklist/legacy/1005/take-ownership/preflight")
check_status "preflight predicts A-1005 would fail adoption" "422" "$PREFLIGHT_1005"
PREFLIGHT_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1005.json'))['invariant'])")
check_status "preflight names the invariant the real call below also fails on" "Bsn.ElevenProof" "$PREFLIGHT_INVARIANT"
echo "== Write path 3: take ownership =="
TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")
check_status "take ownership of A-1002" "201" "$TAKE"
REG_1002=$(python3 -c "import json; print(json.load(open('/tmp/take_1002.json'))['registrationApplicationId'])")
DETAIL_1002=$(curl -sS "$BASE/api/worklist/owned/$REG_1002")
SEAM_AFTER=$(echo "$DETAIL_1002" | json_field "['seams']['aanvrager']")
check_status "adopted case's seam inspector now reads owned" "owned" "$SEAM_AFTER"
BEFORE=$(curl -sS "$BASE/api/diagnostics/legacy-call-count" | json_field "['count']")
curl -sS -o /dev/null -X PUT "$BASE/api/worklist/owned/$REG_1002/details" \
-H "Content-Type: application/json" \
-d '{"surname":"Jansen","initials":"P.","address":null,"email":null,"phone":null,"preferredChannel":"Post"}'
AFTER=$(curl -sS "$BASE/api/diagnostics/legacy-call-count" | json_field "['count']")
check_status "editing the adopted case makes no legacy calls" "$BEFORE" "$AFTER"
DIRECT=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$BASE/api/worklist/legacy/1002/details" \
-H "Content-Type: application/json" -d '{"surname":"x","initials":"x","address":null,"email":null,"phone":null,"preferredChannel":"Post"}')
check_status "direct legacy write to an adopted case is blocked" "409" "$DIRECT"
for pair in "1003:ContactDetails.EmailRequiredForEmailChannel" "1005:Bsn.ElevenProof" "1006:Assessment.MotivationTooShort" "1007:Address.AllPartsRequired"; do
ID="${pair%%:*}"; EXPECTED_INVARIANT="${pair##*:}"
RESULT=$(curl -sS -o /tmp/adopt_fail.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/$ID/take-ownership")
check_status "adoption of A-$ID fails" "422" "$RESULT"
ACTUAL_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/adopt_fail.json')).get('invariant'))")
check_status "A-$ID fails on $EXPECTED_INVARIANT" "$EXPECTED_INVARIANT" "$ACTUAL_INVARIANT"
done
RELEASE_1002=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$BASE/api/worklist/owned/$REG_1002/ownership")
check_status "releasing an edited owned case is blocked" "409" "$RELEASE_1002"
echo "== Owned assessment + conformist boundary (seam D) =="
INVALID_ASSESSMENT=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
"$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment" \
-H "Content-Type: application/json" \
-d '{"verifiedItems":[],"exceptionReason":null,"outcome":"Approved","rejectionCategory":null,"motivation":"too short"}')
check_status "direct invalid assessment payload is rejected" "422" "$INVALID_ASSESSMENT"
ASSESSMENT=$(curl -sS -X POST "$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment" \
-H "Content-Type: application/json" \
-d '{"verifiedItems":["document","land","datum"],"exceptionReason":null,"outcome":"Approved","rejectionCategory":null,"motivation":"Alle bewijsstukken zijn gecontroleerd en akkoord bevonden."}')
CLOSURE_PENDING=$(echo "$ASSESSMENT" | json_field "['closurePending']")
check_status "REG-2026-0002 assessment reports closure pending (open task)" "True" "$CLOSURE_PENDING"
OUTCOME_AFTER=$(curl -sS "$BASE/api/worklist/owned/00000000-0000-0000-0000-000000000002" | json_field "['assessment']['outcome']")
check_status "REG-2026-0002 outcome recorded, not rolled back" "Approved" "$OUTCOME_AFTER"
echo
echo "== Architecture tests =="
if (cd "$(dirname "$0")/../new" && dotnet test tests/Architecture.Tests/Architecture.Tests.csproj -c Release --nologo 2>&1 | tail -5 | grep -q "Failed: 0"); then
pass "all Architecture.Tests pass"
else
fail "Architecture.Tests reported failures"
fi
echo
if [ "$FAILURES" -eq 0 ]; then
echo "All checks passed."
exit 0
else
echo "$FAILURES check(s) failed."
exit 1
fi