test: expand test coverage across core functions, enforce strict fish_indent linting, and add network isolation harnesses #143

Merged
rootiest merged 8 commits from test/network-edge-case-coverage into main 2026-09-10 01:00:47 +00:00
3 changed files with 1154 additions and 0 deletions
Showing only changes of commit de6349f2dc - Show all commits
+694
View File
@@ -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
+31
View File
@@ -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
+429
View File
@@ -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()