🔧 Initial dev copy from live

This commit is contained in:
Rose
2026-04-20 10:43:30 +02:00
commit 96977b576a
284 changed files with 95780 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
"""
Tests for api/updates.py -- specifically the diagnostic code paths added
in fix/223-update-pull-failed-diagnostics (PR #227).
Tests cover the four new branches in _apply_update_inner():
1. fetch fails → network error message
2. pull fails + diverged history → recovery command with git reset --hard
3. pull fails + no upstream tracking → recovery command with set-upstream-to
4. pull fails + generic fallback → raw git output truncated at 300 chars
"""
from pathlib import Path
from unittest.mock import patch, call
import subprocess
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_git_side_effect(*sequence):
"""Return a side_effect function that yields successive (stdout, ok) tuples."""
it = iter(sequence)
def _side_effect(args, cwd, timeout=10):
return next(it)
return _side_effect
# ---------------------------------------------------------------------------
# Path used for patching
# ---------------------------------------------------------------------------
_MODULE = 'api.updates'
# ---------------------------------------------------------------------------
# Tests for _apply_update_inner() diagnostic paths
# ---------------------------------------------------------------------------
class TestApplyUpdateDiagnostics:
"""New code paths introduced in PR #227."""
def _apply(self, target, run_git_side_effect):
"""Call _apply_update_inner with _apply_lock bypassed and _run_git mocked."""
from api import updates
with patch(f'{_MODULE}._run_git', side_effect=run_git_side_effect), \
patch.object(updates, '_apply_lock') as mock_lock:
mock_lock.acquire.return_value = True
mock_lock.release.return_value = None
return updates._apply_update_inner(target)
# ------------------------------------------------------------------
# Path 1: fetch step fails → network error message
# ------------------------------------------------------------------
def test_fetch_failure_returns_network_error_message(self, tmp_path):
"""When git fetch fails, return a human-readable connection error."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
# Call sequence: upstream query, fetch
mock_run_git.side_effect = [
('origin/master', True), # rev-parse @{upstream}
('', False), # fetch fails
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
msg = result['message'].lower()
assert 'could not reach' in msg or 'internet connection' in msg or 'remote repository' in msg
def test_fetch_failure_does_not_attempt_pull(self, tmp_path):
"""When fetch fails, pull is never called."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', False), # fetch fails
]
updates._apply_update_inner('webui')
# Only 2 calls: upstream query + fetch. No pull call.
assert mock_run_git.call_count == 2
# ------------------------------------------------------------------
# Path 2: pull fails + diverged history
# ------------------------------------------------------------------
def test_diverged_history_returns_reset_hard_command(self, tmp_path):
"""Diverged history produces a message with 'reset --hard'."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # status --porcelain (clean)
('Not possible to fast-forward, aborting.', False), # pull fails
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result.get('diverged') is True
msg = result['message']
assert 'reset --hard' in msg
def test_diverged_history_message_contains_compare_ref(self, tmp_path):
"""Diverged history message includes the upstream ref."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/feat/my-feature', True), # upstream query
('', True), # fetch
('', True), # status (clean)
('Your branch and origin have diverged.', False), # pull
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'origin/feat/my-feature' in result['message']
def test_diverged_matching_is_case_insensitive(self, tmp_path):
"""'DIVERGED' in uppercase is still detected."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('DIVERGED from upstream', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result.get('diverged') is True
# ------------------------------------------------------------------
# Path 3: pull fails + no upstream tracking configured
# ------------------------------------------------------------------
def test_no_tracking_returns_set_upstream_command(self, tmp_path):
"""Missing upstream tracking branch produces set-upstream-to message."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch
('', True), # status (clean)
('There is no tracking information for the current branch.', False), # pull
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'set-upstream-to' in result['message']
assert result.get('diverged') is None
def test_no_tracking_alternate_phrasing(self, tmp_path):
"""'does not track' alternate git message is also detected."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('fatal: The current branch local does not track a remote branch.', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'set-upstream-to' in result['message']
def test_no_tracking_message_contains_compare_ref(self, tmp_path):
"""set-upstream-to message includes the upstream ref to configure."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/main', True),
('', True),
('', True),
('no tracking information', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'origin/main' in result['message']
# ------------------------------------------------------------------
# Path 4: pull fails + generic fallback (truncated raw output)
# ------------------------------------------------------------------
def test_generic_failure_includes_truncated_git_output(self, tmp_path):
"""Generic pull failure includes up to 300 chars of git output."""
(tmp_path / '.git').mkdir()
long_error = 'X' * 500 # 500-char error from git
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
(long_error, False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
msg = result['message']
# The raw output in the message must be truncated at 300 chars
assert 'X' * 300 in msg
assert 'X' * 301 not in msg
def test_generic_failure_empty_output_shows_sentinel(self, tmp_path):
"""When git produces no output, message contains a fallback sentinel."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('', False), # pull fails with empty output
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'no output' in result['message'].lower() or result['message']
def test_generic_failure_does_not_set_diverged(self, tmp_path):
"""A generic pull failure must not set diverged=True."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('Some unrecognized git error', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert not result.get('diverged')
# ------------------------------------------------------------------
# Regression: existing success path still works after fetch addition
# ------------------------------------------------------------------
def test_successful_update_still_returns_ok(self, tmp_path):
"""Fetch + status + pull success path returns ok=True (regression guard)."""
(tmp_path / '.git').mkdir()
from api import updates
# Patch the cache's 'checked_at' key directly to avoid the lock
# invalidation block raising. We use a fresh dict swap.
fake_cache = {'webui': None, 'agent': None, 'checked_at': 1}
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git, \
patch(f'{_MODULE}._update_cache', fake_cache), \
patch(f'{_MODULE}._cache_lock'):
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # status (clean working tree)
('Already up to date.', True), # pull succeeds
]
result = updates._apply_update_inner('webui')
assert result['ok'] is True
# ------------------------------------------------------------------
# Agent target works the same as webui target
# ------------------------------------------------------------------
def test_fetch_failure_for_agent_target(self, tmp_path):
"""Fetch failure path also works when target='agent'."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}._AGENT_DIR', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', False), # fetch fails
]
result = updates._apply_update_inner('agent')
assert result['ok'] is False
assert 'could not reach' in result['message'].lower() or \
'internet' in result['message'].lower() or \
'remote' in result['message'].lower()