#!/usr/bin/env bash # next/tests/triggers_e2e.sh — fed-sx triggers Phase 4 (end-to-end). # # The motivating blog-publish-digest flow, driven the whole way: a # trigger binds Article-creates to the flow; the post-append fan-out # starts it; the flow branches on :category, (for newsletters) suspends # on a morning timer, fetches followers (injected), and emits a # DigestSent activity object. Effect-as-data: the flow returns the # emails + DigestSent object (a driver would dispatch/append them) since # a flow can't call kernel gen_servers from inside the drive. # # Each epoch starts fresh gen_servers so instance ids are deterministic. set -uo pipefail cd "$(git rev-parse --show-toplevel)" SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" if [ ! -x "$SX_SERVER" ]; then SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" fi if [ ! -x "$SX_SERVER" ]; then echo "ERROR: sx_server.exe not found." >&2 exit 1 fi VERBOSE="${1:-}" PASS=0; FAIL=0; ERRORS="" TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT # Bring-up shared by every case: registry + store, a 3-follower mock, # the flow registered as blog_digest, and a trigger binding `create` # to it guarded on "the object is an Article". Cfg/AS as the fan-out # expects. Activities differ by :category (urgent / newsletter / draft) # plus a non-Article note. BOOT='trigger_registry:start_link(), flow_store:start_link(), FF = fun(_) -> [f1, f2, f3] end, Flow = blog_publish_digest:build([{fetch_followers, FF}]), flow_store:register_flow(blog_digest, Flow), Guard = fun(A, _) -> case envelope:get_field(object, A) of {ok, O} -> envelope:get_field(type, O) =:= {ok, article}; _ -> false end end, trigger_registry:add(create, trigger_registry:mk_spec(<<116,99>>, blog_digest, Guard, any)), Cfg = [{trigger_registry, trigger_registry}], AS = [{actor_id, alice}],' URGENT='[{type, create}, {actor, alice}, {id, <<117,49>>}, {object, [{type, article}, {category, urgent}]}]' NEWS='[{type, create}, {actor, alice}, {id, <<110,49>>}, {object, [{type, article}, {category, newsletter}]}]' DRAFT='[{type, create}, {actor, alice}, {id, <<100,49>>}, {object, [{type, article}, {category, draft}]}]' NOTE='[{type, create}, {actor, alice}, {id, <<120,49>>}, {object, [{type, note}]}]' cat > "$TMPFILE" <>, <<116,99>>, {ok, 1}}]}\") :name)") (epoch 11) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, AS, Cfg), {ok, {done, {digest_sent, Emails, _}}} = flow_store:status(1), length(Emails) =:= 3\") :name)") ;; DigestSent emit object is well-formed (type, for the article, count) (epoch 12) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, AS, Cfg), {ok, {done, {digest_sent, _, Digest}}} = flow_store:status(1), Digest =:= [{type, digest_sent}, {for, <<117,49>>}, {follower_count, 3}]\") :name)") ;; ── newsletter: suspends on the morning timer, then resumes ─ (epoch 20) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), flow_store:status(1) =:= {ok, {suspended, morning}}\") :name)") ;; advancing the clock (resume the timer) drives it to completion (epoch 21) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), {ok, {flow_done, {digest_sent, Emails, _}}} = flow_store:resume(1, morning_ts), length(Emails) =:= 3\") :name)") ;; before resume no digest exists (still suspended, not done) (epoch 22) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NEWS}, AS, Cfg), case flow_store:status(1) of {ok, {done, _}} -> false; {ok, {suspended, morning}} -> true; _ -> false end\") :name)") ;; ── draft: the :else branch, no emails, no DigestSent ────── (epoch 30) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${DRAFT}, AS, Cfg), flow_store:status(1) =:= {ok, {done, skipped}}\") :name)") ;; ── non-Article note: guard rejects, no flow dispatched ──── (epoch 40) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${NOTE}, AS, Cfg) =:= {ok, []}\") :name)") ;; ── dedup: the same activity arriving twice fires once ───── (epoch 50) (eval "(get (erlang-eval-ast \"${BOOT} pipeline:apply_triggers(${URGENT}, [{actor_id, alice}, {triggers_fired, [{<<117,49>>, <<116,99>>}]}], Cfg) =:= {ok, []}\") :name)") EPOCHS OUTPUT=$(timeout 360 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) check() { local epoch="$1" desc="$2" expected="$3" local actual actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' $0 ~ "^\\(ok-len " e " " { getline; print; exit } $0 ~ "^\\(ok " e " " { print; exit } $0 ~ "^\\(error " e " " { print; exit } ') [ -z "$actual" ] && actual="" if echo "$actual" | grep -qF -- "$expected"; then PASS=$((PASS+1)) [ "$VERBOSE" = "-v" ] && echo " ok $desc" else FAIL=$((FAIL+1)) ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual " fi } check 3 "blog_publish_digest loaded" "blog_publish_digest" check 10 "urgent fans out (audit triple)" "true" check 11 "urgent: 3 emails dispatched" "true" check 12 "urgent: DigestSent object emitted" "true" check 20 "newsletter suspends on timer" "true" check 21 "newsletter resumes -> 3 emails" "true" check 22 "no digest before resume" "true" check 30 "draft -> else branch, skipped" "true" check 40 "non-Article note -> guard rejects" "true" check 50 "duplicate activity fires once" "true" TOTAL=$((PASS+FAIL)) if [ $FAIL -eq 0 ]; then echo "ok $PASS/$TOTAL next/tests/triggers_e2e.sh passed" else echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" echo "$ERRORS" fi [ $FAIL -eq 0 ]