#!/bin/bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENTRYPOINT="${ROOT_DIR}/entrypoint.sh" TEST_TMPDIR="$(mktemp -d)" TEST_BIN_DIR="${TEST_TMPDIR}/bin" CURL_MOCK_ROUTES="${TEST_TMPDIR}/curl.routes" trap 'rm -rf -- "${TEST_TMPDIR}"' EXIT mkdir -p "${TEST_BIN_DIR}" : > "${CURL_MOCK_ROUTES}" export PATH="${TEST_BIN_DIR}:${PATH}" export CURL_MOCK_ROUTES cat > "${TEST_BIN_DIR}/jq" <<'EOF' #!/usr/bin/env python3 import json import sys import urllib.parse def load_json_text(text): text = text.strip() if not text: return None return json.loads(text) def read_json_file(path): if path == "-": return load_json_text(sys.stdin.read()) with open(path, "r", encoding="utf-8") as fh: return load_json_text(fh.read()) args = sys.argv[1:] expr = None files = [] variables = {} raw = False compact = False slurp = False null_input = False i = 0 while i < len(args): arg = args[i] if arg.startswith("-") and len(arg) > 1 and set(arg[1:]).issubset({"r", "n", "c", "s"}): raw = raw or ("r" in arg[1:]) null_input = null_input or ("n" in arg[1:]) slurp = slurp or ("s" in arg[1:]) compact = compact or ("c" in arg[1:]) i += 1 continue if arg == "--arg": variables[args[i + 1]] = args[i + 2] i += 3 continue if arg == "--argjson": variables[args[i + 1]] = json.loads(args[i + 2]) i += 3 continue if arg.startswith("-"): i += 1 continue expr = arg files = args[i + 1 :] break if expr is None: raise SystemExit("jq mock requires an expression") if slurp: if files: docs = [read_json_file(path) for path in files] elif null_input: docs = [] else: docs = [load_json_text(sys.stdin.read())] doc = docs elif files: doc = read_json_file(files[0]) else: doc = None if null_input else load_json_text(sys.stdin.read()) def emit(text): sys.stdout.write(text) def emit_json(value): sys.stdout.write(json.dumps(value, separators=(",", ":"))) if expr == "$value|@uri": emit(urllib.parse.quote(str(variables["value"]), safe="-._~")) elif expr == "length": emit(str(len(doc))) elif expr == "sort_by(.created_at, .version)[] | [.version, .created_at] | @tsv": for item in sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", ""))): emit(f"{item.get('version', '')}\t{item.get('created_at', '')}\n") elif expr == "sort_by(.created_at, .version) | .[0:(length - $keep)]": keep = int(variables["keep"]) items = sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", ""))) emit_json(items[: max(len(items) - keep, 0)]) elif expr == ".[] | [.name, .version, .created_at] | @tsv": for item in doc: emit(f"{item.get('name', '')}\t{item.get('version', '')}\t{item.get('created_at', '')}\n") elif expr == ".[0] + .[1]": if len(doc) < 2: raise SystemExit("jq mock expected two inputs for slurp merge") emit_json(doc[0] + doc[1]) else: raise SystemExit(f"jq mock does not support expression: {expr}") EOF cat > "${TEST_BIN_DIR}/curl" <<'EOF' #!/bin/bash set -euo pipefail method="GET" headers_file="" body_file="" url="" while (($#)); do case "$1" in -s|-S|-sS) shift ;; -H) shift 2 ;; -X) method="$2" shift 2 ;; -D) headers_file="$2" shift 2 ;; -o) body_file="$2" shift 2 ;; -w) shift 2 ;; --) shift break ;; -*) shift ;; *) url="$1" shift ;; esac done if [[ -z "${headers_file}" || -z "${body_file}" || -z "${url}" ]]; then echo "curl mock missing required arguments" >&2 exit 1 fi if [[ -n "${CURL_FAIL_MATCH:-}" && "${method}|${url}" == "${CURL_FAIL_MATCH}" ]]; then echo "curl mock forced failure for ${method} ${url}" >&2 exit 7 fi route_line="$( awk -F $'\t' -v method="${method}" -v url="${url}" '$1 == method && $2 == url { print; exit }' "${CURL_MOCK_ROUTES}" )" if [[ -z "${route_line}" ]]; then echo "curl mock missing route for ${method} ${url}" >&2 exit 1 fi IFS=$'\t' read -r _ _ code status request_id body <<< "${route_line}" printf 'HTTP/1.1 %s %s\r\n' "${code}" "${status}" > "${headers_file}" if [[ -n "${request_id}" ]]; then printf 'x-gitea-request-id: %s\r\n' "${request_id}" >> "${headers_file}" fi printf '\r\n' >> "${headers_file}" printf '%s' "${body}" > "${body_file}" printf '%s' "${code}" EOF chmod +x "${TEST_BIN_DIR}/jq" "${TEST_BIN_DIR}/curl" source "${ENTRYPOINT}" CAPTURE_STDOUT="" CAPTURE_STDERR="" CAPTURE_STATUS=0 assert_eq() { local expected="$1" local actual="$2" local message="${3:-values differ}" if [[ "${expected}" != "${actual}" ]]; then printf 'ASSERTION FAILED: %s\nexpected: %q\nactual: %q\n' "${message}" "${expected}" "${actual}" >&2 return 1 fi } assert_contains() { local haystack="$1" local needle="$2" local message="${3:-substring missing}" if [[ "${haystack}" != *"${needle}"* ]]; then printf 'ASSERTION FAILED: %s\nmissing: %s\ntext: %q\n' "${message}" "${needle}" "${haystack}" >&2 return 1 fi } capture_call() { local stdout_file stderr_file stdout_file="$(mktemp)" stderr_file="$(mktemp)" set +e ( "$@" ) > "${stdout_file}" 2> "${stderr_file}" CAPTURE_STATUS=$? set -e CAPTURE_STDOUT="$(<"${stdout_file}")" CAPTURE_STDERR="$(<"${stderr_file}")" rm -f -- "${stdout_file}" "${stderr_file}" } reset_env() { unset INPUT_KEEP_COUNT INPUT_PACKAGE_NAMES GITEA_SERVER_URL GITEA_REPOSITORY RUNNER_TOKEN PAGE_LIMIT CURL_FAIL_MATCH : > "${CURL_MOCK_ROUTES}" } add_route() { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" >> "${CURL_MOCK_ROUTES}" } run_test() { local name="$1" shift if "$@"; then printf 'ok - %s\n' "${name}" else printf 'not ok - %s\n' "${name}" >&2 return 1 fi } test_trim() { capture_call trim "" assert_eq "" "${CAPTURE_STDOUT}" "trim empty string" capture_call trim " " assert_eq "" "${CAPTURE_STDOUT}" "trim whitespace only" capture_call trim " abc " assert_eq "abc" "${CAPTURE_STDOUT}" "trim trims edges" capture_call trim "a b" assert_eq "a b" "${CAPTURE_STDOUT}" "trim keeps inner whitespace" capture_call trim "abc" assert_eq "abc" "${CAPTURE_STDOUT}" "trim keeps plain values" } test_url_encode() { capture_call url_encode "a b/c?x=1&y=two" assert_eq "a%20b%2Fc%3Fx%3D1%26y%3Dtwo" "${CAPTURE_STDOUT}" "url_encode encodes reserved characters" capture_call url_encode "already%20encoded" assert_eq "already%2520encoded" "${CAPTURE_STDOUT}" "url_encode encodes percent signs" } test_resolve_keep_count() { reset_env capture_call resolve_keep_count assert_eq "2" "${CAPTURE_STDOUT}" "resolve_keep_count default" INPUT_KEEP_COUNT=" 5 " capture_call resolve_keep_count assert_eq "5" "${CAPTURE_STDOUT}" "resolve_keep_count trims input" INPUT_KEEP_COUNT="0" capture_call resolve_keep_count assert_eq "0" "${CAPTURE_STDOUT}" "resolve_keep_count accepts zero" } test_resolve_keep_count_invalid() { local value for value in abc -1 1.5; do reset_env INPUT_KEEP_COUNT="${value}" capture_call resolve_keep_count assert_eq "1" "${CAPTURE_STATUS}" "resolve_keep_count fails for ${value}" assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid keep_count: ${value}" "resolve_keep_count error message" done } test_resolve_package_names() { reset_env INPUT_PACKAGE_NAMES=$' pkg-a , pkg-b\npkg-a,,pkg-c ' capture_call resolve_package_names assert_eq $'pkg-a\npkg-b\npkg-c' "${CAPTURE_STDOUT}" "resolve_package_names trims, dedupes, and keeps order" } test_resolve_package_names_missing() { reset_env capture_call resolve_package_names assert_eq "1" "${CAPTURE_STATUS}" "resolve_package_names fails when empty" assert_contains "${CAPTURE_STDERR}" "ERROR: Missing PACKAGE_NAMES" "resolve_package_names missing message" } test_parse_repo_context() { capture_call parse_repo_context " org/project-name " assert_eq $'org\tproject-name' "${CAPTURE_STDOUT}" "parse_repo_context trims inputs" capture_call parse_repo_context "owner/repo/sub" assert_eq "1" "${CAPTURE_STATUS}" "parse_repo_context rejects nested paths" assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid GITEA_REPOSITORY: owner/repo/sub" "parse_repo_context error message" } test_api_request() { reset_env GITEA_SERVER_URL="https://gitea.example" add_route GET "https://gitea.example/api/v1/test" 200 OK req-123 '{"ok":true}' local body_file headers_file body_file="$(mktemp)" headers_file="$(mktemp)" capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}" assert_eq $'200\t200 OK\treq-123' "${CAPTURE_STDOUT}" "api_request metadata" assert_eq '{"ok":true}' "$(cat "${body_file}")" "api_request writes body" assert_contains "$(cat "${headers_file}")" "x-gitea-request-id: req-123" "api_request writes headers" export CURL_FAIL_MATCH="GET|https://gitea.example/api/v1/test" capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}" assert_eq "1" "${CAPTURE_STATUS}" "api_request propagates curl failure" assert_contains "${CAPTURE_STDERR}" "ERROR: Request failed: GET /api/v1/test" "api_request failure message" unset CURL_FAIL_MATCH } test_fetch_package_versions_404() { reset_env GITEA_SERVER_URL="https://gitea.example" add_route GET "https://gitea.example/api/v1/packages/acme/nuget/missing?page=1&limit=100" 404 "Not Found" req-404 '[]' capture_call fetch_package_versions acme missing token assert_eq "[]" "${CAPTURE_STDOUT}" "fetch_package_versions returns empty array for 404" } test_fetch_package_versions_paginated() { reset_env GITEA_SERVER_URL="https://gitea.example" PAGE_LIMIT=2 add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=2" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"}]' add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=2&limit=2" 200 OK req-2 '[{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]' capture_call fetch_package_versions acme pkg-a token assert_eq '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]' "${CAPTURE_STDOUT}" "fetch_package_versions paginates and merges" } test_collect_package_candidates() { reset_env GITEA_SERVER_URL="https://gitea.example" local candidate_file candidate_file="$(mktemp)" add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]' add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-2 '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]' add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-missing?page=1&limit=100" 404 "Not Found" req-3 '[]' capture_call collect_package_candidates acme 2 "${candidate_file}" token pkg-a pkg-b pkg-missing assert_eq $'2\t4\t3\t1' "${CAPTURE_STDOUT}" "collect_package_candidates summary" assert_eq $'pkg-a\t1.0.0\t2024-01-01T00:00:00Z' "$(cat "${candidate_file}")" "collect_package_candidates chooses oldest version" } test_process_candidates_empty() { reset_env local candidate_file candidate_file="$(mktemp)" capture_call process_candidates acme "${candidate_file}" 0 0 0 0 token assert_contains "${CAPTURE_STDERR}" "Cleanup summary" "process_candidates empty section" assert_contains "${CAPTURE_STDERR}" "Summary: packages=0 versions=0 kept=0 candidates=0 deleted=0 errors=0" "process_candidates empty summary" } test_process_candidates() { reset_env GITEA_SERVER_URL="https://gitea.example" local candidate_file candidate_file="$(mktemp)" printf 'pkg-a\t1.0.0\t2024-01-01T00:00:00Z\npkg-b\t2.0.0\t2024-02-01T00:00:00Z\n' > "${candidate_file}" add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-1 '' add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-b/2.0.0" 500 "Internal Server Error" del-2 '{"error":"boom"}' capture_call process_candidates acme "${candidate_file}" 2 4 3 2 token assert_contains "${CAPTURE_STDERR}" "Delete queue" "process_candidates queue section" assert_contains "${CAPTURE_STDERR}" "Delete results" "process_candidates results section" assert_contains "${CAPTURE_STDERR}" " - failed pkg-b@2.0.0 [500 Internal Server Error] request_id=del-2" "process_candidates failure path" assert_contains "${CAPTURE_STDERR}" "Final summary" "process_candidates final summary section" assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=2 deleted=1 errors=1" "process_candidates summary" } test_main_integration() { reset_env GITEA_SERVER_URL="https://gitea.example" GITEA_REPOSITORY="acme/repo" RUNNER_TOKEN="token" INPUT_KEEP_COUNT="2" INPUT_PACKAGE_NAMES="pkg-a,pkg-b" add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-a '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]' add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-b '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]' add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-a '' capture_call main assert_contains "${CAPTURE_STDERR}" "Cleanup summary" "main summary section" assert_contains "${CAPTURE_STDERR}" "Delete queue" "main queue section" assert_contains "${CAPTURE_STDERR}" "Delete results" "main results section" assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=1 deleted=1 errors=0" "main summary" } tests=( test_trim test_url_encode test_resolve_keep_count test_resolve_keep_count_invalid test_resolve_package_names test_resolve_package_names_missing test_parse_repo_context test_api_request test_fetch_package_versions_404 test_fetch_package_versions_paginated test_collect_package_candidates test_process_candidates_empty test_process_candidates test_main_integration ) failures=0 for test_name in "${tests[@]}"; do if run_test "${test_name}" "${test_name}"; then : else failures=$((failures + 1)) fi done if (( failures > 0 )); then printf '%s test(s) failed\n' "${failures}" >&2 exit 1 fi printf 'all tests passed\n'