diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index 2f36510..50b7202 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -952,16 +952,25 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \ # tag creation GitHub POST /git/refs # Forgejo POST /tags (/git/refs is GET-only) -# release_stub — a curl stub answering one canned response and -# recording the method+path it was asked for. +# release_stub [ …] — a curl stub answering a +# sequence of canned responses and recording the method+path it was asked for. +# The cursor lives in a file because forge_release_create captures +# forgejo_write in a command substitution; an in-memory counter would advance +# only in that subshell and every later request would replay response one. release_stub() { # Globals, not locals: the curl closure below runs long after this # function returns, exactly as stub_writes does above. - STUB_CODE="$1" STUB_BODY="$2" + STUB_CODES=() STUB_BODIES=() + while [ "$#" -gt 0 ]; do + STUB_CODES+=("$1") STUB_BODIES+=("$2") + shift 2 + done + STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}" + printf '0\n' >"$TMP/release-stub-cursor" : >"$WRITES" # shellcheck disable=SC2317 # invoked indirectly, by the forge verbs curl() { - local hdr="" out="" method=GET url="" payload="" + local hdr="" out="" method=GET url="" payload="" fail_on_http=false while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; @@ -969,18 +978,32 @@ release_stub() { -X) method="$2"; shift ;; -d) payload="$2"; shift ;; -F) payload="$payload -F $2"; shift ;; + -f) fail_on_http=true ;; -H) shift ;; -*) ;; *) url="$1" ;; esac shift done - [ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr" - [ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out" + local index code body + if [ "$method" = GET ]; then + code="$STUB_READ_CODE" body="$STUB_READ_BODY" + else + index="$(cat "$TMP/release-stub-cursor")" + code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}" + body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}" + printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor" + fi + [ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr" + [ -n "$out" ] && printf '%s' "$body" >"$out" printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" + if [ "$fail_on_http" = true ]; then + case "$code" in 2*) ;; *) return 22 ;; esac + fi return 0 } } +release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; } GITHUB_API_URL=https://forge.example/api/v1 export GITHUB_API_URL @@ -1023,6 +1046,10 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow release_stub 200 '{"tag_name":"1.2.3"}' check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3 +release_stub 200 '{"tag_name":"1.2.3","draft":true}' +check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3 +release_stub 200 '{"tag_name":"1.2.3","draft":false}' +check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3 release_stub 404 '{}' check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3 release_stub 503 '{}' @@ -1046,9 +1073,22 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \ writes_after forge_tag_create 1.2.3 cafebabe printf 'notes body\n' >"$TMP/notes.md" -release_stub 201 '{"id":42}' +release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; } + +release_stub 201 '{"id":42}' 200 '{}' +release_read 404 '{}' check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \ - writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" + release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" +check "the create stages a draft" 0 '"draft":true' cat "$WRITES" +zero_asset_writes() { + release_stub 201 '{"id":42}' 200 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1 + local got + got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')" + [ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; } +} +check "the zero-asset path creates then publishes" 0 "" zero_asset_writes # Assets: the hook contract permits any filename the consumer drops in # RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw @@ -1062,18 +1102,88 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \ printf 'x\n' >"$TMP/release asset.tgz" printf 'y\n' >"$TMP/a&b.tgz" -release_stub 201 '{"id":42}' +release_stub 201 '{"id":42}' 201 '{}' 200 '{}' +release_read 404 '{}' check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \ - writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" -release_stub 201 '{"id":42}' + release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" +release_stub 201 '{"id":42}' 201 '{}' 200 '{}' +release_read 404 '{}' check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \ - writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" -release_stub 201 '{"id":42}' + release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" +release_stub 201 '{"id":42}' 201 '{}' 200 '{}' +release_read 404 '{}' check "the upload targets the created release id" 0 "releases/42/assets" \ - writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" -release_stub 201 '{"id":42}' + release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" +release_stub 201 '{"id":42}' 201 '{}' 200 '{}' +release_read 404 '{}' check "the asset rides as a multipart attachment" 0 "attachment=@" \ - writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" + release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" + +happy_asset_writes() { + release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1 + local got want + got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')" + want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42' + [ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; } +} +check "assets upload in argument order before the release is published" 0 "" happy_asset_writes + +upload_failure() { + release_stub 201 '{"id":42}' 500 '{}' 204 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" +} +check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure +check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES" + +flip_failure() { + release_stub 201 '{"id":42}' 500 '{}' 204 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" +} +check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure +check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES" + +create_failure() { + release_stub 500 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" +} +check "a create failure returns non-zero" 1 "HTTP 500" create_failure +check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES" + +missing_release_id() { + release_stub 201 '{}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" +} +check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id +check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES" + +stranded_cleanup_failure() { + release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}' + release_read 404 '{}' + forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" +} +check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure +check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure + +release_stub 204 '{}' 201 '{"id":42}' 200 '{}' +release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}' +forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1 +stranded_draft_order() { + local got + got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')" + [ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; } +} +check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order + +release_stub 409 '{}' +release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}' +forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1 +check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES" # --- the github twins address their own paths ---------------------------- . "$ROOT/lib/forge-github.sh"