test: expand test coverage across core functions, enforce strict fish_indent linting, and add network isolation harnesses #143
@@ -0,0 +1,694 @@
|
||||
#!/usr/bin/env fish
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Hermetic unit tests for network-dependent fish functions with full mock coverage
|
||||
# and failure edge-case testing:
|
||||
# - gi (gitignore generation, API fetch, deduplication, error handling)
|
||||
# - gip, gip4, gip6 (public IP resolution, timeouts, IPv4/IPv6 fallback, failure notices)
|
||||
# - qr (terminal QR code generator, qrencode local bypass, curl fallback & network drop)
|
||||
# - bd-pull (Gitea issues sync, missing tokens, API failures, malformed JSON, linking)
|
||||
# - _auto_pull_sync (background safe fast-forward, dirty trees, network drop on fetch)
|
||||
# - gitup (remote fetch & status, network drop handling)
|
||||
# - git-clean (remote fetch/prune, orphaned branch detection & deletion)
|
||||
# - config-update (upstream fetch, up-to-date check, dry-run, force stash & restore)
|
||||
# - repo-open (origin URL parsing, offline local tracking, ls-remote fallback & drop)
|
||||
# - fzf-update (git pull/clone network failure handling)
|
||||
#
|
||||
# MODE: isolated
|
||||
|
||||
source (realpath (dirname (status filename)))/lib.fish
|
||||
set -p fish_function_path $repo_root/functions
|
||||
|
||||
set -gx TERM xterm-256color
|
||||
set -g TMPDIRS
|
||||
|
||||
# Ensure git operations inside tests are hermetic and do not prompt
|
||||
set -gx GIT_AUTHOR_NAME t
|
||||
set -gx GIT_AUTHOR_EMAIL t@t
|
||||
set -gx GIT_COMMITTER_NAME t
|
||||
set -gx GIT_COMMITTER_EMAIL t@t
|
||||
set -gx GIT_CONFIG_COUNT 2
|
||||
set -gx GIT_CONFIG_KEY_0 commit.gpgsign
|
||||
set -gx GIT_CONFIG_VALUE_0 false
|
||||
set -gx GIT_CONFIG_KEY_1 init.defaultBranch
|
||||
set -gx GIT_CONFIG_VALUE_1 main
|
||||
set -gx GIT_TERMINAL_PROMPT 0
|
||||
|
||||
# Helper to create temporary git repos
|
||||
function new_repo
|
||||
set -l d (mktemp -d)
|
||||
set -ga TMPDIRS $d
|
||||
git -C $d init -q
|
||||
git -C $d config user.email t@t
|
||||
git -C $d config user.name t
|
||||
git -C $d config commit.gpgsign false
|
||||
git -C $d config core.hooksPath /dev/null
|
||||
printf '%s\n' $d
|
||||
end
|
||||
|
||||
# Build a hermetic mock bin directory
|
||||
set -g MOCK_DIR (mktemp -d)
|
||||
set -ga TMPDIRS $MOCK_DIR
|
||||
set -l real_git (command -s git)
|
||||
|
||||
# 1. Mock curl
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'if [ -n "$MOCK_CURL_LOG" ]; then' \
|
||||
' printf "%s\n" "$*" >> "$MOCK_CURL_LOG"' \
|
||||
'fi' \
|
||||
'if [ -n "$MOCK_CURL_HANDLER" ] && [ -x "$MOCK_CURL_HANDLER" ]; then' \
|
||||
' exec "$MOCK_CURL_HANDLER" "$@"' \
|
||||
'fi' \
|
||||
'if [ -n "$MOCK_CURL_DELAY" ]; then' \
|
||||
' sleep "$MOCK_CURL_DELAY"' \
|
||||
'fi' \
|
||||
'if [ -n "$MOCK_CURL_STDERR" ]; then' \
|
||||
' printf "%s\n" "$MOCK_CURL_STDERR" >&2' \
|
||||
'fi' \
|
||||
'if [ -f "$MOCK_CURL_BODY_FILE" ]; then' \
|
||||
' cat "$MOCK_CURL_BODY_FILE"' \
|
||||
'elif [ -n "$MOCK_CURL_BODY" ]; then' \
|
||||
' printf "%s\n" "$MOCK_CURL_BODY"' \
|
||||
'fi' \
|
||||
'exit "${MOCK_CURL_STATUS:-0}"' > $MOCK_DIR/curl
|
||||
chmod +x $MOCK_DIR/curl
|
||||
|
||||
# 2. Mock git shim
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'if [ -n "$MOCK_GIT_LOG" ]; then' \
|
||||
' printf "%s\n" "$*" >> "$MOCK_GIT_LOG"' \
|
||||
'fi' \
|
||||
'if [ -n "$MOCK_GIT_HANDLER" ] && [ -x "$MOCK_GIT_HANDLER" ]; then' \
|
||||
' exec "$MOCK_GIT_HANDLER" "$@"' \
|
||||
'fi' \
|
||||
'for a in "$@"; do' \
|
||||
' if [ "$a" = "fetch" ] && [ -n "$MOCK_GIT_FAIL_FETCH" ]; then' \
|
||||
' echo "fatal: unable to access: Could not resolve host" >&2' \
|
||||
' exit "${MOCK_GIT_FETCH_STATUS:-128}"' \
|
||||
' fi' \
|
||||
' if [ "$a" = "pull" ] && [ -n "$MOCK_GIT_FAIL_PULL" ]; then' \
|
||||
' echo "fatal: unable to access: Could not resolve host" >&2' \
|
||||
' exit "${MOCK_GIT_PULL_STATUS:-128}"' \
|
||||
' fi' \
|
||||
' if [ "$a" = "clone" ] && [ -n "$MOCK_GIT_FAIL_CLONE" ]; then' \
|
||||
' echo "fatal: unable to access: Could not resolve host" >&2' \
|
||||
' exit "${MOCK_GIT_CLONE_STATUS:-128}"' \
|
||||
' fi' \
|
||||
' if [ "$a" = "ls-remote" ] && [ -n "$MOCK_GIT_FAIL_LS_REMOTE" ]; then' \
|
||||
' echo "fatal: unable to access: Could not resolve host" >&2' \
|
||||
' exit "${MOCK_GIT_LS_REMOTE_STATUS:-128}"' \
|
||||
' fi' \
|
||||
'done' \
|
||||
"exec $real_git \"\$@\"" > $MOCK_DIR/git
|
||||
chmod +x $MOCK_DIR/git
|
||||
|
||||
# 3. Mock bd CLI
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'if [ "$1" = "create" ]; then' \
|
||||
' echo "Created issue bd-99"' \
|
||||
' echo "{\"id\":\"bd-99\"}" >> .beads/issues.jsonl' \
|
||||
' exit 0' \
|
||||
'elif [ "$1" = "sync" ]; then' \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'exit 0' > $MOCK_DIR/bd
|
||||
chmod +x $MOCK_DIR/bd
|
||||
|
||||
# 4. Mock qrencode
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'if [ -n "$MOCK_QRENCODE_LOG" ]; then' \
|
||||
' printf "%s\n" "$*" >> "$MOCK_QRENCODE_LOG"' \
|
||||
'fi' \
|
||||
'echo "LOCAL_QRENCODE: $*"' \
|
||||
'exit 0' > $MOCK_DIR/qrencode
|
||||
chmod +x $MOCK_DIR/qrencode
|
||||
|
||||
# Prepend MOCK_DIR to PATH so mocks take precedence
|
||||
set -gx PATH $MOCK_DIR $PATH
|
||||
|
||||
function reset_mocks
|
||||
set -e MOCK_CURL_STATUS
|
||||
set -e MOCK_CURL_BODY
|
||||
set -e MOCK_CURL_BODY_FILE
|
||||
set -e MOCK_CURL_STDERR
|
||||
set -e MOCK_CURL_DELAY
|
||||
set -e MOCK_CURL_LOG
|
||||
set -e MOCK_CURL_HANDLER
|
||||
set -e MOCK_GIT_FAIL_FETCH
|
||||
set -e MOCK_GIT_FETCH_STATUS
|
||||
set -e MOCK_GIT_FAIL_PULL
|
||||
set -e MOCK_GIT_PULL_STATUS
|
||||
set -e MOCK_GIT_FAIL_CLONE
|
||||
set -e MOCK_GIT_CLONE_STATUS
|
||||
set -e MOCK_GIT_FAIL_LS_REMOTE
|
||||
set -e MOCK_GIT_LS_REMOTE_STATUS
|
||||
set -e MOCK_GIT_LOG
|
||||
set -e MOCK_GIT_HANDLER
|
||||
set -e MOCK_QRENCODE_LOG
|
||||
end
|
||||
|
||||
function cleanup
|
||||
for d in $TMPDIRS
|
||||
test -n "$d"; and command rm -rf $d
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. gi (gitignore generator)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: gi"
|
||||
|
||||
reset_mocks
|
||||
set -gx MOCK_CURL_BODY "c,cpp,python,rust"
|
||||
set -l list_out (gi -l)
|
||||
check "gi -l: returns API target list" "c,cpp,python,rust" "$list_out"
|
||||
|
||||
# Complete network drop on list
|
||||
set -gx MOCK_CURL_STATUS 7
|
||||
set -gx MOCK_CURL_BODY ""
|
||||
gi -l >/dev/null 2>&1
|
||||
check "gi -l: returns 0 per contract" 0 $status
|
||||
|
||||
# stdout mode with valid content
|
||||
reset_mocks
|
||||
set -gx MOCK_CURL_BODY "# Python gitignore\n*.pyc\n__pycache__/"
|
||||
set -l stdout_out (gi -s python)
|
||||
check "gi -s: prints fetched patterns to stdout" "# Python gitignore\n*.pyc\n__pycache__/" "$stdout_out"
|
||||
|
||||
# stdout mode with network failure / 404 (curl exit 22)
|
||||
reset_mocks
|
||||
set -gx MOCK_CURL_STATUS 22
|
||||
set -gx MOCK_CURL_BODY ""
|
||||
gi -s invalid_target >/dev/null 2>&1
|
||||
check "gi -s: API failure returns 1" 1 $status
|
||||
|
||||
# Append mode outside git repository
|
||||
reset_mocks
|
||||
set -l non_git_dir (mktemp -d)
|
||||
set -ga TMPDIRS $non_git_dir
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd $non_git_dir
|
||||
gi python >/dev/null 2>&1
|
||||
set -l rc $status
|
||||
builtin cd $prev_pwd
|
||||
check "gi <target>: outside git repo returns 1" 1 $rc
|
||||
end
|
||||
|
||||
# Append mode inside git repo + deduplication
|
||||
reset_mocks
|
||||
set -l repo (new_repo)
|
||||
set -gx MOCK_CURL_BODY "# Python gitignore\n*.pyc"
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd $repo
|
||||
gi python >/dev/null 2>&1
|
||||
check "gi python: first run returns 0" 0 $status
|
||||
check "gi python: creates .gitignore" true (test -f "$repo/.gitignore"; and echo true; or echo false)
|
||||
set -l first_lines (count (command cat "$repo/.gitignore"))
|
||||
|
||||
# Second run with identical pattern triggers deduplication
|
||||
gi python >/dev/null 2>&1
|
||||
check "gi python: second run returns 0" 0 $status
|
||||
set -l second_lines (count (command cat "$repo/.gitignore"))
|
||||
check "gi python: deduplication prevents duplicate append" $first_lines $second_lines
|
||||
|
||||
# Network failure during append skips modifying file
|
||||
set -gx MOCK_CURL_STATUS 7
|
||||
gi ruby >/dev/null 2>&1
|
||||
set -l third_lines (count (command cat "$repo/.gitignore"))
|
||||
check "gi: network drop during append preserves file" $second_lines $third_lines
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. gip, gip4, gip6 (IP resolution)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: gip, gip4, gip6"
|
||||
|
||||
# Create a specialized curl handler for gip IPv4/IPv6 dispatch
|
||||
set -l gip_handler $MOCK_DIR/gip_curl.sh
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'for a in "$@"; do' \
|
||||
' if [ "$a" = "-4" ]; then' \
|
||||
' if [ -n "$MOCK_FAIL_IPV4" ]; then exit "${MOCK_IPV4_STATUS:-28}"; fi' \
|
||||
' echo "198.51.100.1"' \
|
||||
' exit 0' \
|
||||
' fi' \
|
||||
' if [ "$a" = "-6" ]; then' \
|
||||
' if [ -n "$MOCK_FAIL_IPV6" ]; then exit "${MOCK_IPV6_STATUS:-28}"; fi' \
|
||||
' echo "2001:db8::1"' \
|
||||
' exit 0' \
|
||||
' fi' \
|
||||
'done' \
|
||||
'exit 0' > $gip_handler
|
||||
chmod +x $gip_handler
|
||||
|
||||
reset_mocks
|
||||
set -gx MOCK_CURL_HANDLER $gip_handler
|
||||
|
||||
# gip: both IPv4 and IPv6 succeed
|
||||
set -l out_both (gip)
|
||||
check "gip: both succeed outputs IPv4" true (string match -q '*IPv4: 198.51.100.1*' -- $out_both; and echo true; or echo false)
|
||||
check "gip: both succeed outputs IPv6" true (string match -q '*IPv6: 2001:db8::1*' -- $out_both; and echo true; or echo false)
|
||||
|
||||
# gip: IPv4 timeout / failure, IPv6 success
|
||||
set -gx MOCK_FAIL_IPV4 1
|
||||
set -l out_v4_fail (gip)
|
||||
check "gip: IPv4 failure reports 'Not detected'" true (string match -q '*IPv4: Not detected*' -- $out_v4_fail; and echo true; or echo false)
|
||||
check "gip: IPv4 failure still reports IPv6" true (string match -q '*IPv6: 2001:db8::1*' -- $out_v4_fail; and echo true; or echo false)
|
||||
|
||||
# gip: IPv4 success, IPv6 failure
|
||||
set -e MOCK_FAIL_IPV4
|
||||
set -gx MOCK_FAIL_IPV6 1
|
||||
set -l out_v6_fail (gip)
|
||||
check "gip: IPv6 failure reports IPv4" true (string match -q '*IPv4: 198.51.100.1*' -- $out_v6_fail; and echo true; or echo false)
|
||||
check "gip: IPv6 failure reports 'Not detected'" true (string match -q '*IPv6: Not detected*' -- $out_v6_fail; and echo true; or echo false)
|
||||
|
||||
# gip: complete network drop (both fail)
|
||||
set -gx MOCK_FAIL_IPV4 1
|
||||
set -gx MOCK_FAIL_IPV6 1
|
||||
set -l out_all_fail (gip)
|
||||
check "gip: complete drop reports IPv4 Not detected" true (string match -q '*IPv4: Not detected*' -- $out_all_fail; and echo true; or echo false)
|
||||
check "gip: complete drop reports IPv6 Not detected" true (string match -q '*IPv6: Not detected*' -- $out_all_fail; and echo true; or echo false)
|
||||
|
||||
# gip4: success
|
||||
set -e MOCK_FAIL_IPV4
|
||||
set -l out_gip4 (gip4)
|
||||
check "gip4: success prints IP" "198.51.100.1" "$out_gip4"
|
||||
check "gip4: success exits 0" 0 $status
|
||||
|
||||
# gip4: failure
|
||||
set -gx MOCK_FAIL_IPV4 1
|
||||
set -gx MOCK_IPV4_STATUS 7
|
||||
gip4 >/dev/null 2>&1
|
||||
check "gip4: failure returns curl status" 7 $status
|
||||
|
||||
# gip6: success
|
||||
set -e MOCK_FAIL_IPV6
|
||||
set -l out_gip6 (gip6)
|
||||
check "gip6: success prints IPv6" "2001:db8::1" "$out_gip6"
|
||||
check "gip6: success exits 0" 0 $status
|
||||
|
||||
# gip6: failure / unsupported network
|
||||
set -gx MOCK_FAIL_IPV6 1
|
||||
set -l out_gip6_err (gip6 2>&1)
|
||||
check "gip6: failure exits 1" 1 $status
|
||||
check "gip6: failure prints notice" true (string match -q '*IPv6 is currently unavailable*' -- $out_gip6_err; and echo true; or echo false)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. qr (QR code generator)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: qr"
|
||||
|
||||
reset_mocks
|
||||
# Case A: Local qrencode tool is present
|
||||
set -gx MOCK_QRENCODE_LOG (mktemp)
|
||||
set -ga TMPDIRS $MOCK_QRENCODE_LOG
|
||||
set -l qr_local (qr "hello-world")
|
||||
check "qr: local qrencode is preferred offline" true (string match -q '*LOCAL_QRENCODE*' -- $qr_local; and echo true; or echo false)
|
||||
check "qr: curl is never called when qrencode exists" 0 (test -f $MOCK_DIR/curl_log; and count (cat $MOCK_DIR/curl_log); or echo 0)
|
||||
|
||||
# Case B: Local qrencode is missing, falling back to curl
|
||||
function type
|
||||
if test "$argv[1]" = "-q" -a "$argv[2]" = "qrencode"
|
||||
return 1
|
||||
end
|
||||
builtin type $argv
|
||||
end
|
||||
|
||||
set -gx MOCK_CURL_BODY "UTF8_QR_BODY"
|
||||
set -l qr_curl (qr "hello-curl")
|
||||
check "qr: fallback to curl when qrencode is missing" "UTF8_QR_BODY" "$qr_curl"
|
||||
|
||||
# Network drop during curl fallback
|
||||
set -gx MOCK_CURL_STATUS 7
|
||||
set -gx MOCK_CURL_BODY ""
|
||||
qr "fail" >/dev/null 2>&1
|
||||
check "qr: curl network drop returns non-zero" 7 $status
|
||||
|
||||
# Argument-based curl fallback
|
||||
set -gx MOCK_CURL_STATUS 0
|
||||
set -gx MOCK_CURL_BODY "TEXT_QR"
|
||||
set -l qr_arg (qr "arg-text")
|
||||
check "qr: argument works via curl fallback" "TEXT_QR" "$qr_arg"
|
||||
|
||||
functions -e type
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. bd-pull (Gitea issues sync)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: bd-pull"
|
||||
|
||||
reset_mocks
|
||||
# Missing required arguments & env vars
|
||||
bd-pull >/dev/null 2>&1
|
||||
check "bd-pull: missing repo argument exits 1" 1 $status
|
||||
|
||||
begin
|
||||
set -e GITEA_TOKEN
|
||||
set -e GITEA_URL
|
||||
bd-pull rootiest/test >/dev/null 2>&1
|
||||
check "bd-pull: missing GITEA_TOKEN exits 1" 1 $status
|
||||
|
||||
set -gx GITEA_TOKEN "test_token"
|
||||
bd-pull rootiest/test >/dev/null 2>&1
|
||||
check "bd-pull: missing GITEA_URL exits 1" 1 $status
|
||||
end
|
||||
|
||||
# Complete network drop / empty response
|
||||
set -gx GITEA_TOKEN "dummy_token"
|
||||
set -gx GITEA_URL "https://git.test"
|
||||
set -gx MOCK_CURL_STATUS 7
|
||||
set -gx MOCK_CURL_BODY ""
|
||||
set -l out_drop (bd-pull rootiest/test)
|
||||
check "bd-pull: network drop reports no unlinked issues" true (string match -q '*No unlinked issues found*' -- $out_drop; and echo true; or echo false)
|
||||
|
||||
# Empty JSON issue list
|
||||
set -gx MOCK_CURL_STATUS 0
|
||||
set -gx MOCK_CURL_BODY "[]"
|
||||
set -l out_empty (bd-pull rootiest/test)
|
||||
check "bd-pull: empty issue list handled cleanly" true (string match -q '*No unlinked issues found*' -- $out_empty; and echo true; or echo false)
|
||||
|
||||
# Issues already linked with [ID] prefix
|
||||
set -gx MOCK_CURL_BODY '[{"number": 1, "title": "[bd-1] Already linked issue"}]'
|
||||
set -l out_already (bd-pull rootiest/test)
|
||||
check "bd-pull: already-linked issues skipped" true (string match -q '*No unlinked issues found*' -- $out_already; and echo true; or echo false)
|
||||
|
||||
# Unlinked issue found: creates bead, patches title, commits & pushes
|
||||
set -l bd_remote (new_repo)
|
||||
git -C $bd_remote config --bool core.bare true
|
||||
set -l bd_repo (new_repo)
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd $bd_repo
|
||||
git remote add origin $bd_remote
|
||||
mkdir -p .beads
|
||||
touch .beads/issues.jsonl
|
||||
git add .beads/issues.jsonl
|
||||
git commit -q -m "init beads"
|
||||
git push -q -u origin main
|
||||
|
||||
set -gx MOCK_CURL_BODY '[{"number": 2, "title": "Web original issue without ID"}]'
|
||||
set -l out_unlinked (bd-pull rootiest/test 2>&1)
|
||||
check "bd-pull: unlinked issue is linked" true (string match -q '*Linked 1 issues*' -- $out_unlinked; and echo true; or echo false)
|
||||
|
||||
# Verify git commit was made
|
||||
set -l last_msg (git log -1 --pretty=%s)
|
||||
check "bd-pull: commits synced IDs to git" "chore: sync local IDs for web issues" "$last_msg"
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. _auto_pull_sync (background fast-forward)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: _auto_pull_sync"
|
||||
|
||||
reset_mocks
|
||||
# Non-git directory
|
||||
_auto_pull_sync /tmp >/dev/null 2>&1
|
||||
check "_auto_pull_sync: non-git directory returns 1" 1 $status
|
||||
|
||||
# Git repo without upstream branch
|
||||
set -l sync_repo (new_repo)
|
||||
echo "test" > $sync_repo/file.txt
|
||||
git -C $sync_repo add file.txt
|
||||
git -C $sync_repo commit -q -m "initial"
|
||||
_auto_pull_sync $sync_repo >/dev/null 2>&1
|
||||
check "_auto_pull_sync: missing upstream returns 1" 1 $status
|
||||
|
||||
# Setup upstream branch for sync_repo
|
||||
set -l sync_upstream (new_repo)
|
||||
git -C $sync_upstream config --bool core.bare true
|
||||
git -C $sync_repo remote add origin $sync_upstream
|
||||
git -C $sync_repo push -q -u origin main >/dev/null 2>&1
|
||||
|
||||
# Dirty working tree (unstaged modifications)
|
||||
echo "dirty" >> $sync_repo/file.txt
|
||||
_auto_pull_sync $sync_repo >/dev/null 2>&1
|
||||
check "_auto_pull_sync: dirty worktree returns 1" 1 $status
|
||||
git -C $sync_repo checkout -q -- file.txt
|
||||
|
||||
# Dirty index (staged modifications)
|
||||
echo "staged" >> $sync_repo/staged.txt
|
||||
git -C $sync_repo add staged.txt
|
||||
_auto_pull_sync $sync_repo >/dev/null 2>&1
|
||||
check "_auto_pull_sync: dirty index returns 1" 1 $status
|
||||
git -C $sync_repo reset -q HEAD staged.txt
|
||||
command rm -f $sync_repo/staged.txt
|
||||
|
||||
# Clean repo, network failure on git fetch
|
||||
set -gx MOCK_GIT_FAIL_FETCH 1
|
||||
_auto_pull_sync $sync_repo >/dev/null 2>&1
|
||||
check "_auto_pull_sync: fetch failure returns 1" 1 $status
|
||||
|
||||
# Clean repo, successful fast-forward
|
||||
reset_mocks
|
||||
# Add commit to upstream
|
||||
set -l peer_clone (new_repo)
|
||||
git -C $peer_clone clone -q $sync_upstream $peer_clone/work
|
||||
echo "new commit" > $peer_clone/work/new.txt
|
||||
git -C $peer_clone/work add new.txt
|
||||
git -C $peer_clone/work commit -q -m "upstream work"
|
||||
git -C $peer_clone/work push -q origin main
|
||||
|
||||
_auto_pull_sync $sync_repo >/dev/null 2>&1
|
||||
check "_auto_pull_sync: clean fast-forward returns 0" 0 $status
|
||||
check "_auto_pull_sync: changes merged into working tree" true (test -f $sync_repo/new.txt; and echo true; or echo false)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 6. gitup (fetch and status)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: gitup"
|
||||
|
||||
reset_mocks
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd /tmp
|
||||
gitup >/dev/null 2>&1
|
||||
check "gitup: outside git repo exits 1" 1 $status
|
||||
|
||||
set -l r (new_repo)
|
||||
builtin cd $r
|
||||
echo a > a && git add a && git commit -q -m a
|
||||
|
||||
# Network failure on git fetch
|
||||
set -gx MOCK_GIT_FAIL_FETCH 1
|
||||
gitup >/dev/null 2>&1
|
||||
check "gitup: network drop during fetch exits non-zero" true (test $status -ne 0; and echo true; or echo false)
|
||||
|
||||
# Successful fetch
|
||||
reset_mocks
|
||||
set -l up_out (gitup 2>&1)
|
||||
check "gitup: success exits 0" 0 $status
|
||||
check "gitup: displays git status output" true (string match -q '*On branch main*' -- $up_out; and echo true; or echo false)
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. git-clean (fetch --prune and delete orphaned branches)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: git-clean"
|
||||
|
||||
reset_mocks
|
||||
git-clean --help >/dev/null 2>&1
|
||||
check "git-clean: --help exits 0" 0 $status
|
||||
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
set -l r (new_repo)
|
||||
builtin cd $r
|
||||
echo a > a && git add a && git commit -q -m a
|
||||
|
||||
# Network drop during git fetch --prune
|
||||
set -gx MOCK_GIT_FAIL_FETCH 1
|
||||
# git-clean continues to local cleanup even if fetch fails
|
||||
set -l clean_out (git-clean 2>&1)
|
||||
check "git-clean: network drop handled, reports tidy" true (string match -q '*No orphaned branches found*' -- $clean_out; and echo true; or echo false)
|
||||
|
||||
# Orphaned branch detection and cleanup
|
||||
reset_mocks
|
||||
# Simulate an orphaned branch with [gone] tracking
|
||||
git branch orphaned-feat
|
||||
# Create git-clean handler that simulates git branch -vv showing ': gone]'
|
||||
set -l clean_git_handler $MOCK_DIR/git_clean_shim.sh
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'for a in "$@"; do' \
|
||||
' if [ "$a" = "-vv" ]; then' \
|
||||
' echo " main 1234567 [origin/main] initial"' \
|
||||
' echo " orphaned-feat abcdef0 [origin/orphaned-feat: gone] feature"' \
|
||||
' exit 0' \
|
||||
' fi' \
|
||||
'done' \
|
||||
"exec $real_git \"\$@\"" > $clean_git_handler
|
||||
chmod +x $clean_git_handler
|
||||
|
||||
set -gx MOCK_GIT_HANDLER $clean_git_handler
|
||||
set -l clean_del_out (git-clean 2>&1)
|
||||
check "git-clean: detects and deletes orphaned branch" true (string match -q '*Deleting orphaned local branches*' -- $clean_del_out; and echo true; or echo false)
|
||||
check "git-clean: orphaned branch was deleted" false (git rev-parse --verify --quiet orphaned-feat >/dev/null 2>&1; and echo true; or echo false)
|
||||
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 8. config-update (configuration repository sync)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: config-update"
|
||||
|
||||
reset_mocks
|
||||
config-update --help >/dev/null 2>&1
|
||||
check "config-update: --help exits 0" 0 $status
|
||||
|
||||
# Non-git CONFIG_DIR (using mock handler)
|
||||
set -l cfg_update_handler $MOCK_DIR/cfg_update_shim.sh
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'for a in "$@"; do' \
|
||||
' if [ "$a" = "fetch" ] && [ -n "$MOCK_CFG_FAIL_FETCH" ]; then' \
|
||||
' exit 1' \
|
||||
' fi' \
|
||||
'done' \
|
||||
"exec $real_git \"\$@\"" > $cfg_update_handler
|
||||
chmod +x $cfg_update_handler
|
||||
set -gx MOCK_GIT_HANDLER $cfg_update_handler
|
||||
|
||||
# Network failure on upstream fetch
|
||||
set -l fake_home (mktemp -d)
|
||||
set -ga TMPDIRS $fake_home
|
||||
set -l fake_fish_cfg "$fake_home/.config/fish"
|
||||
mkdir -p (dirname $fake_fish_cfg)
|
||||
set -l cfg_repo (new_repo)
|
||||
command cp -r $cfg_repo $fake_fish_cfg
|
||||
|
||||
# Test config-update against fake_fish_cfg by setting HOME
|
||||
begin
|
||||
set -lx HOME $fake_home
|
||||
set -gx MOCK_CFG_FAIL_FETCH 1
|
||||
config-update >/dev/null 2>&1
|
||||
check "config-update: network fetch failure returns 1" 1 $status
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 9. repo-open (origin URL normalization and browser deep-linking)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: repo-open"
|
||||
|
||||
reset_mocks
|
||||
repo-open --help >/dev/null 2>&1
|
||||
check "repo-open: --help exits 0" 0 $status
|
||||
|
||||
# Outside git repo
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd /tmp
|
||||
repo-open -p >/dev/null 2>&1
|
||||
check "repo-open: outside git repo returns 1" 1 $status
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
# In git repo without origin
|
||||
set -l r_no_origin (new_repo)
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd $r_no_origin
|
||||
repo-open -p >/dev/null 2>&1
|
||||
check "repo-open: without origin remote returns 1" 1 $status
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
# In git repo with GitHub origin remote
|
||||
set -l r_gh (new_repo)
|
||||
git -C $r_gh remote add origin "https://github.com/rootiest/fish-config.git"
|
||||
echo init > $r_gh/file && git -C $r_gh add file && git -C $r_gh commit -q -m init
|
||||
begin
|
||||
set -l prev_pwd $PWD
|
||||
builtin cd $r_gh
|
||||
|
||||
# Remote ls-remote network failure falls back to default branch
|
||||
set -gx MOCK_GIT_FAIL_LS_REMOTE 1
|
||||
set -l url_fallback (repo-open -p)
|
||||
check "repo-open: ls-remote network drop falls back to default branch" "https://github.com/rootiest/fish-config" "$url_fallback"
|
||||
|
||||
# With ls-remote success for current branch
|
||||
reset_mocks
|
||||
set -l gh_ls_handler $MOCK_DIR/gh_ls_shim.sh
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'for a in "$@"; do' \
|
||||
' if [ "$a" = "ls-remote" ]; then' \
|
||||
' echo "abcdef01 refs/heads/main"' \
|
||||
' exit 0' \
|
||||
' fi' \
|
||||
'done' \
|
||||
"exec $real_git \"\$@\"" > $gh_ls_handler
|
||||
chmod +x $gh_ls_handler
|
||||
set -gx MOCK_GIT_HANDLER $gh_ls_handler
|
||||
|
||||
# Test gitlab provider normalization
|
||||
git remote set-url origin "git@gitlab.com:rootiest/fish-config.git"
|
||||
git checkout -q -b feat-test
|
||||
set -l url_gl (repo-open -p)
|
||||
check "repo-open: gitlab ssh url normalized with branch tree" "https://gitlab.com/rootiest/fish-config/-/tree/feat-test" "$url_gl"
|
||||
|
||||
# Test gitea provider normalization
|
||||
git remote set-url origin "https://git.rootiest.dev/rootiest/fish-config.git"
|
||||
git config browse.provider gitea
|
||||
set -l url_gitea (repo-open -p)
|
||||
check "repo-open: gitea url normalized with src/branch" "https://git.rootiest.dev/rootiest/fish-config/src/branch/feat-test" "$url_gitea"
|
||||
|
||||
builtin cd $prev_pwd
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 10. fzf-update (fzf install / git pull)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
section "network isolation: fzf-update"
|
||||
|
||||
reset_mocks
|
||||
# When ~/.fzf exists, git pull fails due to network drop
|
||||
set -l fake_home_fzf (mktemp -d)
|
||||
set -ga TMPDIRS $fake_home_fzf
|
||||
mkdir -p $fake_home_fzf/.fzf
|
||||
|
||||
begin
|
||||
set -lx HOME $fake_home_fzf
|
||||
set -gx MOCK_GIT_FAIL_PULL 1
|
||||
fzf-update >/dev/null 2>&1
|
||||
check "fzf-update: git pull network drop returns non-zero" true (test $status -ne 0; and echo true; or echo false)
|
||||
end
|
||||
|
||||
# When ~/.fzf does not exist, git clone fails due to network drop
|
||||
set -l fake_home_no_fzf (mktemp -d)
|
||||
set -ga TMPDIRS $fake_home_no_fzf
|
||||
|
||||
begin
|
||||
set -lx HOME $fake_home_no_fzf
|
||||
set -gx MOCK_GIT_FAIL_CLONE 1
|
||||
fzf-update >/dev/null 2>&1
|
||||
check "fzf-update: git clone network drop returns non-zero" true (test $status -ne 0; and echo true; or echo false)
|
||||
end
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Teardown and Final Report
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
cleanup
|
||||
report
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env fish
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Isolated test suite for Python helper scripts network isolation and edge case mocking.
|
||||
# Exercises scripts/sync-labels.py under offline conditions with full mock coverage.
|
||||
#
|
||||
# Runs isolated (no # MODE: marker, defaulting to isolated).
|
||||
|
||||
source (realpath (dirname (status filename)))/lib.fish
|
||||
|
||||
section "python network isolation: sync-labels.py"
|
||||
|
||||
set -l py_script $repo_root/tests/test_sync_labels.py
|
||||
check "tests/test_sync_labels.py exists" true (test -f $py_script; and echo true; or echo false)
|
||||
|
||||
set -l py_lines (python3 $py_script -v 2>&1)
|
||||
set -l py_status $status
|
||||
|
||||
for line in $py_lines
|
||||
if string match -qr '^\s*(test_\w+)\s+\([^)]+\)\s+\.\.\.\s+(\w+)' -- $line
|
||||
set -l match (string match -r '^\s*(test_\w+)\s+\([^)]+\)\s+\.\.\.\s+(\w+)' -- $line)
|
||||
set -l tname $match[2]
|
||||
set -l tres $match[3]
|
||||
check "$tname" ok (string lower $tres)
|
||||
end
|
||||
end
|
||||
|
||||
check "test_sync_labels.py suite exited 0" 0 $py_status
|
||||
|
||||
report
|
||||
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Comprehensive unit tests for scripts/sync-labels.py with network isolation."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import importlib.util
|
||||
|
||||
# Load scripts/sync-labels.py via importlib (handling the hyphen in filename)
|
||||
_script_path = Path(__file__).resolve().parent.parent / "scripts" / "sync-labels.py"
|
||||
_spec = importlib.util.spec_from_file_location("sync_labels", _script_path)
|
||||
sl = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["sync_labels"] = sl
|
||||
_spec.loader.exec_module(sl)
|
||||
|
||||
|
||||
def _make_http_error(url, code, msg, headers, body_bytes):
|
||||
fp = io.BytesIO(body_bytes)
|
||||
return urllib.error.HTTPError(url, code, msg, headers, fp)
|
||||
|
||||
|
||||
class TestRequestNetworkDrop(unittest.TestCase):
|
||||
"""Edge cases for complete network drop and connection failures."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_connection_refused(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
ConnectionRefusedError(111, "Connection refused")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("Connection refused", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_dns_resolution_failure(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
socket.gaierror(-2, "Name or service not known")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://nonexistent.invalid/test")
|
||||
self.assertIn("Name or service not known", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_network_unreachable(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
OSError(101, "Network is unreachable")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("Network is unreachable", str(ctx.exception))
|
||||
|
||||
|
||||
class TestRequestTimeouts(unittest.TestCase):
|
||||
"""Edge cases for slow or hung connections and timeouts."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_socket_timeout(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = urllib.error.URLError(
|
||||
socket.timeout("The read operation timed out")
|
||||
)
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("timed out", str(ctx.exception))
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_timeout_parameter_passed(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"ok": true}'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
sl.request("https://api.github.com/test")
|
||||
mock_urlopen.assert_called_once()
|
||||
_, kwargs = mock_urlopen.call_args
|
||||
self.assertEqual(kwargs.get("timeout"), sl.TIMEOUT)
|
||||
|
||||
|
||||
class TestRequestHttpCodes(unittest.TestCase):
|
||||
"""Edge cases for HTTP 4xx and 5xx responses, rate limiting, and errors."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_401_unauthorized(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
401,
|
||||
"Unauthorized",
|
||||
{},
|
||||
b'{"message": "Bad credentials"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test", token="invalid")
|
||||
self.assertIn("HTTP 401", str(ctx.exception))
|
||||
self.assertIn("Bad credentials", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_403_rate_limit(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
403,
|
||||
"Forbidden",
|
||||
{"X-RateLimit-Remaining": "0"},
|
||||
b'{"message": "API rate limit exceeded for user"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 403", str(ctx.exception))
|
||||
self.assertIn("rate limit exceeded", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_404_not_found(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
404,
|
||||
"Not Found",
|
||||
{},
|
||||
b'{"message": "Not Found"}',
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 404", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_500_server_error(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
500,
|
||||
"Internal Server Error",
|
||||
{},
|
||||
b"Internal Server Error",
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_http_502_bad_gateway(self, mock_urlopen):
|
||||
err = _make_http_error(
|
||||
"https://api.github.com/test",
|
||||
502,
|
||||
"Bad Gateway",
|
||||
{},
|
||||
b"<html><body>502 Bad Gateway</body></html>",
|
||||
)
|
||||
mock_urlopen.side_effect = err
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.request("https://api.github.com/test")
|
||||
self.assertIn("HTTP 502", str(ctx.exception))
|
||||
err.close()
|
||||
|
||||
|
||||
class TestRequestPayloads(unittest.TestCase):
|
||||
"""Edge cases for payloads, parsing, and request formatting."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_valid_json(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'[{"name": "Kind/Bug", "color": "ee0701"}]'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
res = sl.request("https://api.github.com/test")
|
||||
self.assertEqual(res, [{"name": "Kind/Bug", "color": "ee0701"}])
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_empty_body_204(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b""
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
res = sl.request("https://api.github.com/test", method="DELETE")
|
||||
self.assertIsNone(res)
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_headers_and_auth(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"{}"
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
sl.request(
|
||||
"https://api.github.com/test",
|
||||
token="secret_token",
|
||||
method="POST",
|
||||
payload={"name": "test"},
|
||||
)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
self.assertEqual(req.get_header("Authorization"), "Bearer secret_token")
|
||||
self.assertEqual(req.get_header("Accept"), "application/json")
|
||||
self.assertEqual(req.get_header("Content-type"), "application/json")
|
||||
self.assertEqual(req.get_method(), "POST")
|
||||
self.assertEqual(req.data, b'{"name": "test"}')
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_request_corrupted_json_payload(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"name": "broken", invalid_json'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = None
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
sl.request("https://api.github.com/test")
|
||||
|
||||
|
||||
class TestPaginate(unittest.TestCase):
|
||||
"""Edge cases for pagination across API responses."""
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_single_page(self, mock_req):
|
||||
mock_req.return_value = [{"name": "A"}, {"name": "B"}]
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 2)
|
||||
mock_req.assert_called_once_with(
|
||||
"https://api.test?page=1&per_page=50", token=None
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_multiple_pages(self, mock_req):
|
||||
page1 = [{"name": f"item{i}"} for i in range(50)]
|
||||
page2 = [{"name": f"item{i}"} for i in range(50, 70)]
|
||||
mock_req.side_effect = [page1, page2]
|
||||
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 70)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_exact_page_boundary(self, mock_req):
|
||||
page1 = [{"name": f"item{i}"} for i in range(50)]
|
||||
page2 = []
|
||||
mock_req.side_effect = [page1, page2]
|
||||
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(len(res), 50)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_paginate_empty(self, mock_req):
|
||||
mock_req.return_value = []
|
||||
res = sl.paginate("https://api.test?page={page}&per_page={per_page}", per_page=50)
|
||||
self.assertEqual(res, [])
|
||||
mock_req.assert_called_once()
|
||||
|
||||
|
||||
class TestLabelOperations(unittest.TestCase):
|
||||
"""Edge cases for label CRUD operations and usage querying."""
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_usage_count_zero(self, mock_req):
|
||||
mock_req.return_value = []
|
||||
count = sl.usage_count("Unused Label", "token")
|
||||
self.assertEqual(count, 0)
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/issues"
|
||||
f"?labels=Unused%20Label&state=all&per_page=100",
|
||||
token="token",
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_usage_count_active(self, mock_req):
|
||||
mock_req.return_value = [{"number": 1}, {"number": 2}]
|
||||
count = sl.usage_count("Priority/High", "token")
|
||||
self.assertEqual(count, 2)
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/issues"
|
||||
f"?labels=Priority%2FHigh&state=all&per_page=100",
|
||||
token="token",
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_create_label(self, mock_req):
|
||||
label = {"name": "Test/Label", "color": "123456", "description": "Desc"}
|
||||
sl.create_label(label, "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels",
|
||||
token="tok",
|
||||
method="POST",
|
||||
payload=label,
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_update_label(self, mock_req):
|
||||
label = {"name": "Area/Prompt & Theme", "color": "abcdef", "description": "New"}
|
||||
sl.update_label(label, "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels/Area%2FPrompt%20%26%20Theme",
|
||||
token="tok",
|
||||
method="PATCH",
|
||||
payload={
|
||||
"new_name": "Area/Prompt & Theme",
|
||||
"color": "abcdef",
|
||||
"description": "New",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("sync_labels.request")
|
||||
def test_delete_label(self, mock_req):
|
||||
sl.delete_label("Reviewed/Won't Fix", "tok")
|
||||
mock_req.assert_called_once_with(
|
||||
f"{sl.GITHUB_API}/repos/{sl.GITHUB_REPO}/labels/Reviewed%2FWon%27t%20Fix",
|
||||
token="tok",
|
||||
method="DELETE",
|
||||
)
|
||||
|
||||
|
||||
class TestRunAndMainWorkflow(unittest.TestCase):
|
||||
"""End-to-end execution testing dry-run, live execution, and error handling."""
|
||||
|
||||
def setUp(self):
|
||||
self.env_patcher = patch.dict(os.environ, {}, clear=True)
|
||||
self.env_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.env_patcher.stop()
|
||||
|
||||
def test_run_missing_token_not_dry_run_raises(self):
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.run(dry_run=False)
|
||||
self.assertIn("GH_MIRROR_TOKEN is not set", str(ctx.exception))
|
||||
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_gitea_empty_raises(self, mock_gitea):
|
||||
os.environ[sl.TOKEN_ENV] = "dummy_token"
|
||||
mock_gitea.return_value = []
|
||||
with self.assertRaises(sl.SyncError) as ctx:
|
||||
sl.run(dry_run=False)
|
||||
self.assertIn("Gitea returned no labels", str(ctx.exception))
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("sync_labels.delete_label")
|
||||
@patch("sync_labels.update_label")
|
||||
@patch("sync_labels.create_label")
|
||||
@patch("sync_labels.usage_count")
|
||||
@patch("sync_labels.fetch_github_labels")
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_dry_run_makes_no_mutating_calls(
|
||||
self, mock_gitea, mock_gh, mock_usage, mock_create, mock_update, mock_delete, mock_stdout
|
||||
):
|
||||
mock_gitea.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "New", "color": "222222", "description": "Created"},
|
||||
{"name": "Change", "color": "333333", "description": "Updated"},
|
||||
]
|
||||
mock_gh.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "Change", "color": "000000", "description": "Old"},
|
||||
{"name": "ExtraUnused", "color": "444444", "description": "Deleted"},
|
||||
{"name": "ExtraUsed", "color": "555555", "description": "Kept"},
|
||||
]
|
||||
mock_usage.side_effect = lambda name, token: 1 if name == "ExtraUsed" else 0
|
||||
|
||||
# In dry run, token is not strictly required
|
||||
status = sl.run(dry_run=True)
|
||||
self.assertEqual(status, 0)
|
||||
mock_create.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("sync_labels.delete_label")
|
||||
@patch("sync_labels.update_label")
|
||||
@patch("sync_labels.create_label")
|
||||
@patch("sync_labels.usage_count")
|
||||
@patch("sync_labels.fetch_github_labels")
|
||||
@patch("sync_labels.fetch_gitea_labels")
|
||||
def test_run_live_executes_plan(
|
||||
self, mock_gitea, mock_gh, mock_usage, mock_create, mock_update, mock_delete, mock_stdout
|
||||
):
|
||||
os.environ[sl.TOKEN_ENV] = "my_token"
|
||||
mock_gitea.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "New", "color": "222222", "description": "Created"},
|
||||
{"name": "Change", "color": "333333", "description": "Updated"},
|
||||
]
|
||||
mock_gh.return_value = [
|
||||
{"name": "Keep", "color": "111111", "description": "Same"},
|
||||
{"name": "Change", "color": "000000", "description": "Old"},
|
||||
{"name": "ExtraUnused", "color": "444444", "description": "Deleted"},
|
||||
{"name": "ExtraUsed", "color": "555555", "description": "Kept"},
|
||||
]
|
||||
mock_usage.side_effect = lambda name, token: 1 if name == "ExtraUsed" else 0
|
||||
|
||||
status = sl.run(dry_run=False)
|
||||
self.assertEqual(status, 0)
|
||||
mock_create.assert_called_once_with(
|
||||
{"name": "New", "color": "222222", "description": "Created"}, "my_token"
|
||||
)
|
||||
mock_update.assert_called_once()
|
||||
mock_delete.assert_called_once_with("ExtraUnused", "my_token")
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_main_self_test(self, mock_stdout):
|
||||
self.assertEqual(sl.main(["--self-test"]), 0)
|
||||
|
||||
@patch("sync_labels.run")
|
||||
def test_main_sync_error_exit_code(self, mock_run):
|
||||
mock_run.side_effect = sl.SyncError("Boom")
|
||||
with patch("sys.stderr", new=io.StringIO()) as fake_err:
|
||||
rc = sl.main(["--dry-run"])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("error: Boom", fake_err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user