diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index ad1bd65..8409acf 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -746,6 +746,7 @@ check "no api base refuses" 1 "cannot reach the forge" \ # .user.login -> .actor.login. Mutation-verified: collapsing add/remove or # emitting .user instead of .actor each reds its own case (#4853). timeline_stub() { + : >"$timeline_calls" # shellcheck disable=SC2317 # invoked indirectly, by forge_api curl() { local hdr="" out="" url="" @@ -753,14 +754,44 @@ timeline_stub() { case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac shift done - printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr" + printf '%s\n' "$url" >>"$timeline_calls" + local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}" + case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac + if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then + if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then + page_body="${FAKE_TL_PAGES[$((page - 1))]}" + else + page_body='[]' + fi + page_total="$(jq 'length' <<<"$page_body")" + fi + { + printf 'HTTP/1.1 200 OK\r\n' + [ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total" + printf '\r\n' + } >"$hdr" case "$url" in - *timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;; + *timeline*) printf '%s' "$page_body" >"$out" ;; *) printf '[]' >"$out" ;; esac return 0 } } +timeline_page() { + jq -nc --argjson first "$1" --argjson count "$2" ' + [range($first; $first + $count) + | { + type: "label", + body: "1", + user: {login: "setter"}, + label: {name: "needs-ruling"}, + created_at: ("event-" + tostring) + }] + ' +} +timeline_calls="$TMP/timeline_calls" +FAKE_TL_PAGES=() +FAKE_TL_HEADERS=yes FAKE_TIMELINE='[ {"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"}, {"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"}, @@ -777,6 +808,48 @@ check "forge_timeline drops non-label events" 0 "" \ test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0 check "forge_timeline uses .actor.login, not a bare .user" 0 "" \ jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null + +# Forgejo's timeline endpoint lies consistently: x-total-count echoes the +# current page size. With 151 events its pages declare 50, 50, 50 and 1, so +# strict pagination stops successfully after page 1 and drops the newest 101 +# events. Exhaustion is safe only here because timelines are append-only. +FAKE_TL_PAGES=( + "$(timeline_page 1 50)" + "$(timeline_page 51 50)" + "$(timeline_page 101 50)" + "$(timeline_page 151 1)" +) +timeline_stub +tl="$(REPO=o/r forge_timeline 188)" +check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \ + test "$(jq 'length' <<<"$tl")" = 151 +check "forge_timeline retains the newest event beyond page one" 0 "" \ + jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null + +# A collection exactly divisible by the page size needs one final empty read; +# stopping after the second full page cannot prove exhaustion. +FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)") +timeline_stub +exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')" +check "exhaustive pagination terminates after an empty page" 0 "" \ + test "$exhaustive_count" = 100 +check "an exactly-full exhaustive gather reads the empty third page" 0 "" \ + test "$(wc -l <"$timeline_calls")" = 3 + +# The exhaustive path's completeness proof is the short page itself; it must +# never consult the endpoint's missing or dishonest total header. +FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)") +FAKE_TL_HEADERS=no +timeline_stub +check "exhaustive pagination needs no x-total-count header" 0 "" \ + eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length' +FAKE_TL_HEADERS=yes + +check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \ + forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline' +check "exhaustive pagination refuses a non-GET method" 1 "GET" \ + forge_api --paginate-exhaustive --method POST 'repos/o/r/issues/188/timeline' + # Unreadable: curl fails. Status must surface through forge_timeline itself # (not a later jq), or the ruling ladder invents a verdict on a half-read. # shellcheck disable=SC2317