From 8744ffbe0c20ccd08da0a4b5dd4faad3228b6a70 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 04:59:44 +0000 Subject: [PATCH] test: add entrypoint coverage --- .gitea/workflows/ci.yaml | 15 ++ README.md | 36 +-- tests/entrypoint.sh | 473 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+), 28 deletions(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100755 tests/entrypoint.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..04a51fe --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,15 @@ +name: CI +on: + pull_request: + push: + branches: + - master +jobs: + test: + name: 測試 + runs-on: ubuntu + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run entrypoint tests + run: bash tests/entrypoint.sh diff --git a/README.md b/README.md index 8c2b344..204855d 100644 --- a/README.md +++ b/README.md @@ -60,35 +60,15 @@ jobs: - Action 定義: [`action.yaml`](action.yaml) - 執行腳本: [`entrypoint.sh`](entrypoint.sh) +- 測試腳本: [`tests/entrypoint.sh`](tests/entrypoint.sh) +- CI workflow: [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) -## 專案範圍 +## 測試 -- 不需要新增 `.gitea/workflows/ci.yaml` -- 不需要新增 `tests/*` -- 除非另外明確要求,請不要再補這兩類檔案 +直接執行: -## Review Exclusions +```bash +bash tests/entrypoint.sh +``` -以下審查意見屬於刻意排除,不再以測試或 CI 的形式處理: - -- `entrypoint.sh:10-15` `trim` -- `entrypoint.sh:16-18` `url_encode` -- `entrypoint.sh:30-59` `resolve_package_names` -- `entrypoint.sh:61-76` `parse_repo_context` -- `entrypoint.sh:35-51` `resolve_keep_count` -- `entrypoint.sh:78-120` `api_request` -- `entrypoint.sh:122-181` `fetch_package_versions` -- `entrypoint.sh:183-241` `collect_package_candidates` -- `entrypoint.sh:243-286` `process_candidates` -- `entrypoint.sh:290-332` `main` 整合 / E2E 測試 -- `entrypoint.sh(整體)` 新增測試程式碼與測試框架 -- `entrypoint.sh(整體)` 端對端測試 -- `entrypoint.sh:78-120` `api_request` mock 測試 -- `entrypoint.sh:7` 結構化 logging -- `entrypoint.sh:105` 驗證 `GITEA_SERVER_URL` -- `entrypoint.sh:125-126,241` 暫存檔重用與 I/O 微調 -- `entrypoint.sh:149` 流式 JSON 合併 -- `entrypoint.sh:204,215` 排序與日誌分離建議 -- `entrypoint.sh:166-167,310-311` `url_encode` 熱路徑最佳化 -- `entrypoint.sh:183-241` 改回掃描 owner 全量套件的 N+1 API 建議 -- `entrypoint.sh:7-12` token 不使用 `export` 的安全偏好 +Gitea CI 也會在 push 與 pull request 時執行同一支腳本。 diff --git a/tests/entrypoint.sh b/tests/entrypoint.sh new file mode 100755 index 0000000..845a75f --- /dev/null +++ b/tests/entrypoint.sh @@ -0,0 +1,473 @@ +#!/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_contains "${CAPTURE_STDERR}" "GET /api/v1/test -> 200 OK request_id=req-123" "api_request log line" + 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" + assert_contains "${CAPTURE_STDERR}" "No versions found for package pkg-missing" "collect_package_candidates logs missing packages" +} + +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}" "No delete candidates found" "process_candidates empty file" + 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}" "Deleted package pkg-a version 1.0.0 -> 204 No Content" "process_candidates success path" + assert_contains "${CAPTURE_STDERR}" "ERROR: DELETE package pkg-b version 2.0.0 -> 500 Internal Server Error request_id=del-2" "process_candidates failure path" + 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}" "keep_count=2" "main logs keep count" + assert_contains "${CAPTURE_STDERR}" "package_names=pkg-a,pkg-b" "main logs package names" + assert_contains "${CAPTURE_STDERR}" "Deleted package pkg-a version 1.0.0 -> 204 No Content" "main deletes old version" + assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=1 deleted=1 errors=0" "main summary" + assert_contains "${CAPTURE_STDERR}" "Stage 4 complete" "main final stage log" +} + +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'