Files
LEDMatrix/test/web_interface/test_state_reconciliation.py
T
d6c5f97c13 Test suite overhaul + fixes for the three bugs it uncovered (#441)
* ci: run the whole test tree and make the plugin-safety job assert something real

The unit-tests CI job ran an explicit 24-file allowlist that had rotted:
63 of 90 test files (display, vegas, store manager, web API, web_interface)
never ran on a PR. The job now runs all of test/ (minus test/plugins, which
the plugin-safety job owns) so new test files are enrolled by default and
any exclusion needs a visible, commented --ignore.

The plugin-safety job was a green no-op: plugins/ is empty in CI, so every
test skipped with 'Manifest not found'. It now renders a bundled
deterministic fixture plugin (test/fixtures/plugins/ci-fixture-plugin,
golden images included for all 8 default sizes) via LEDMATRIX_PLUGINS_DIR,
and sets LEDMATRIX_REQUIRE_PLUGINS=1 so discovering zero plugins fails
loudly instead of skipping green. The per-plugin suites document that they
target dev machines with real plugins installed.

Coverage is now measured and enforced in exactly one place — the CI
unit-tests step (--cov=src --cov=web_interface --cov-fail-under=45, from a
measured 47% baseline). pytest.ini previously declared --cov-fail-under=30
but CI always passed --no-cov, so the gate had never run anywhere; local
pytest is now coverage-free and fast.

Enabling the 63 unenrolled files surfaced three cases of test rot, fixed
here: test_display_controller_vegas_tick.py could not collect without the
hardware rgbmatrix module (now uses the emulator convention), the
state-reconciliation unrecoverable-cache tests broke when production added
the is_plugin_uninstalled tombstone check (bare Mock returned truthy),
and test_get_system_status assumed the optional psutil dependency
(now installed via requirements-test.txt and guarded by importorskip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: replace can't-fail tests with real assertions

test_font_manager.py was 5 of 6 tests shaped as 'try: call(); assert True /
except: assert True' — running in CI while unable to fail on any
regression. Rewritten against the real FontManager API and the bundled
assets/fonts: returned font types, cache-hit identity, distinct entries per
size, default-font fallback for unknown families and corrupt files
(recorded in failed_loads), BDF native-size reading, text measurement, and
cache lifecycle.

test_display_manager.py's test_draw_text ended in 'assert True'; it now
renders onto a known-black canvas and asserts pixels were actually lit —
which required un-breaking the fixture's freetype MagicMock so draw_text's
isinstance check doesn't silently swallow the draw.

test_display_controller.py carried a permanently-skipped test whose skip
reason already declared it redundant; deleted.

Both display test files now set EMULATOR=true before importing
display_manager (the same convention as test_display_dirty_tracking.py) so
they collect standalone instead of depending on which test module imports
display_manager first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: cover the untested fragile logic (compatibility gate, secrets, config merges, durations, skin cards)

New unit tests for pure or filesystem-only logic that previously had zero
direct coverage:

- test_compatibility.py: the semver install gate (parse_semver suffix
  handling, every range operator, TRUSTWORTHY_FLOOR behavior for cores
  reporting untrustworthy versions, 'more restrictive wins', and the
  malformed-manifest shapes that used to raise).
- test/web_interface/test_secret_helpers.py: the canonical x-secret
  helpers — find/separate/mask/remove, array-item secrets, no input
  mutation, and a separate->recombine round-trip.
- test/web_interface/test_api_v3_helpers.py: the module-level helpers
  behind the plugin config save endpoint (_is_plugin_update_available,
  _coerce_to_bool including the int==1 quirk, deep_merge including its
  shared-subtree shallowness, _parse_form_value, dotted-key-aware
  _get_schema_property/_set_nested_value).
- test_base_plugin_duration.py: get_display_duration's full coercion
  ladder (instance attr -> config -> 15.0), including the bool-is-int
  quirk where display_duration=True means one second.
- test_config_manager_secrets.py: the secrets round-trip — deep-merge on
  load, strip on save, group pruning, the load fast path — and two
  characterized sharp edges marked SUSPECTED BUG: an unreadable secrets
  file at save time writes secrets into config.json in plaintext, and a
  same-mtime-same-size content swap is served stale.
- test_schema_manager_merge.py: merge_with_defaults branch behavior (None
  replacement vs falsey preservation, dict-vs-scalar mismatches, arrays
  replaced wholesale, defaults never mutated).
- test_skin_system.py (extended): render_skin_card shares _render_game's
  3-strike counter but never resets it on success — the asymmetry is
  pinned in both directions, along with card fallthrough and the disable
  interaction between the two paths.

Suspected bugs are characterized, not fixed — each carries a comment so a
future behavior change is deliberate rather than accidental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: add drift guards for cross-file contracts

Three guard suites that pin contracts spanning multiple files, where one
side changing unilaterally breaks the other silently:

- test_version_comparison_consistency.py: the repo's four version
  comparators (compatibility.parse_semver, api_v3's packaging-based
  _is_plugin_update_available, store_manager update_plugin's raw string
  equality, skin_runtime._major) answer differently on the same inputs.
  A table pins each one's verdict; update_plugin is driven through its
  real code path to show the SUSPECTED BUGs: 'v1.2.0' vs '1.2.0'
  triggers a full reinstall the UI calls unnecessary, and a locally-ahead
  plugin gets downgraded. A pairwise-ordering check keeps parse_semver
  agreeing with packaging on plain X.Y.Z.
- test/web_interface/test_secret_separation_parity.py: api_v3.py carries
  three inline copies of find_secret_fields/separate_secrets that lack
  the canonical module's array-item support. The copy count is asserted
  exact (it may only go down; new copies must import
  src/web_interface/secret_helpers), the missing-array-support gap is
  asserted so it can't grow silently, and the canonical behavior that
  migration will adopt is documented executably.
- test_discovery_path_contract.py: the three 'where is plugin X'
  resolvers (PluginManager discovery, StoreManager._find_plugin_path,
  SchemaManager.get_schema_path) agree on the configured directory, and
  their divergent fallback chains are characterized. Also pins the
  .standalone-backup- naming contract shared by store rollback and
  discovery, and _resolve_skin_target's path-traversal rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* test: address review feedback — fixture lifecycle, test names, ClassVar

- ci-fixture-plugin: call display_manager.clear() before rendering (per
  plugin guidelines — the fixture should model a well-behaved plugin),
  add a class docstring, and document why Pillow is deliberately not
  pinned in its requirements.txt (core dependency; harness installs
  nothing).
- Rename two tests whose names contradicted their assertions:
  test_unparseable_core_version_is_compatible ->
  test_unparseable_core_with_high_floor_is_blocked, and
  test_unreadable_secrets_file... -> test_corrupt_secrets_file...
- Annotate TestGetSchemaProperty.SCHEMA as ClassVar (RUF012).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* ci: allow manual test.yml runs via workflow_dispatch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success

Fixes the three suspected bugs this PR's characterization tests pinned,
flipping those tests to assert the corrected behavior:

- plugins/store: ONE shared update comparator. New
  compatibility.is_update_available() (PEP 440 via packaging) is now used
  by both the web UI's update badge (api_v3._is_plugin_update_available
  is a thin alias) and store_manager.update_plugin's reinstall decision.
  Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0'
  triggered a full reinstall the UI called unnecessary, and a locally-
  ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED.
  Now equivalent spellings skip the reinstall and locally-ahead versions
  are never downgraded; unparseable versions still reconcile by
  reinstalling from the registry.

- config: save_config and save_config_atomic now refuse (ConfigError)
  when config_secrets.json exists but cannot be loaded. Both previously
  proceeded without stripping, writing the merged secrets into
  config.json in plaintext. The shared _load_secrets_for_save() helper
  raises with an actionable message instead; a missing secrets file is
  still fine (nothing to strip), and _migrate_config's catch-all keeps
  boot resilient.

- skins: render_skin_card resets _skin_failures on both success paths
  (vegas card returned, or mode renderer handled), mirroring
  _render_game. Transient card failures no longer accumulate across a
  session until they permanently disable a working skin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

* fix: harden shared comparator edges from review

- is_update_available: reject truthy non-string versions (a malformed
  manifest can carry a number; packaging raises TypeError on those) by
  surfacing the mismatch instead of raising.
- store_manager.update_plugin: drop the truthiness gate around the
  comparator so a missing version on either side follows the shared
  'no update' verdict, keeping the store consistent with the UI badge;
  a missing manifest still uses the reinstall recovery path.
- config_manager._load_secrets_for_save: catch only expected read/parse
  failures (OSError/ValueError/RecursionError) so implementation bugs
  propagate as themselves, and log with traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 10:17:30 -04:00

507 lines
20 KiB
Python

"""
Tests for state reconciliation system.
"""
import unittest
import tempfile
import shutil
import json
from pathlib import Path
from unittest.mock import Mock, patch
from src.plugin_system.state_reconciliation import (
StateReconciliation,
InconsistencyType,
FixAction,
ReconciliationResult
)
from src.plugin_system.state_manager import PluginStateManager, PluginStateStatus
class TestStateReconciliation(unittest.TestCase):
"""Test state reconciliation system."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = Path(tempfile.mkdtemp())
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
# Create mock managers
self.state_manager = Mock(spec=PluginStateManager)
self.config_manager = Mock()
self.plugin_manager = Mock()
# Initialize reconciliation system
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir
)
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir)
def test_reconcile_no_inconsistencies(self):
"""Test reconciliation with no inconsistencies."""
# Setup: All states are consistent
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {"plugin1": Mock()}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify
self.assertIsInstance(result, ReconciliationResult)
self.assertEqual(len(result.inconsistencies_found), 0)
self.assertTrue(result.reconciliation_successful)
def test_plugin_missing_in_config(self):
"""Test detection of plugin missing in config."""
# Setup: Plugin exists on disk but not in config
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
def test_plugin_missing_on_disk(self):
"""Test detection of plugin missing on disk."""
# Setup: Plugin in config but not on disk
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Don't create plugin directory
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
self.assertFalse(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
def test_enabled_state_mismatch(self):
"""Test detection of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
def test_auto_fix_plugin_missing_in_config(self):
"""Test auto-fix of plugin missing in config."""
# Setup
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to track calls
saved_configs = []
def save_config(config):
saved_configs.append(config)
self.config_manager.save_config = save_config
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify fix was attempted
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.assertEqual(len(saved_configs), 1)
self.assertIn("plugin1", saved_configs[0])
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
def test_auto_fix_enabled_state_mismatch(self):
"""Test auto-fix of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# config.json is the source of truth for enabled state. The fix syncs
# the state manager to match config (config says True → state set True),
# rather than overwriting the config with the stale state value.
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
def test_multiple_inconsistencies(self):
"""Test reconciliation with multiple inconsistencies."""
# Setup: Multiple plugins with different issues
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}, # Exists in config but not on disk
# plugin2 exists on disk but not in config
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin2 directory (exists on disk but not in config)
plugin2_dir = self.plugins_dir / "plugin2"
plugin2_dir.mkdir()
manifest_path = plugin2_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 2"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify multiple inconsistencies found
self.assertGreaterEqual(len(result.inconsistencies_found), 2)
# Check types
inconsistency_types = [inc.inconsistency_type for inc in result.inconsistencies_found]
self.assertIn(InconsistencyType.PLUGIN_MISSING_ON_DISK, inconsistency_types)
self.assertIn(InconsistencyType.PLUGIN_MISSING_IN_CONFIG, inconsistency_types)
def test_reconciliation_with_exception(self):
"""Test reconciliation handles exceptions gracefully."""
# Setup: State manager raises exception when getting states
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.side_effect = Exception("State manager error")
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify error is handled - reconciliation may still succeed if other sources work
self.assertIsInstance(result, ReconciliationResult)
# Note: Reconciliation may still succeed if other sources provide valid state
def test_fix_failure_handling(self):
"""Test that fix failures are handled correctly."""
# Setup: Plugin missing in config, but save fails
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to raise exception
self.config_manager.save_config.side_effect = Exception("Save failed")
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected but not fixed
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertEqual(len(result.inconsistencies_fixed), 0)
self.assertEqual(len(result.inconsistencies_manual), 1)
def test_get_config_state_handles_exception(self):
"""Test that _get_config_state handles exceptions."""
# Setup: Config manager raises exception
self.config_manager.load_config.side_effect = Exception("Config error")
# Call method directly
state = self.reconciler._get_config_state()
# Verify empty state returned
self.assertEqual(state, {})
def test_get_disk_state_handles_exception(self):
"""Test that _get_disk_state handles exceptions."""
# Setup: Make plugins_dir inaccessible
with patch.object(self.reconciler, 'plugins_dir', create=True) as mock_dir:
mock_dir.exists.side_effect = Exception("Disk error")
mock_dir.iterdir.side_effect = Exception("Disk error")
# Call method directly
state = self.reconciler._get_disk_state()
# Verify empty state returned
self.assertEqual(state, {})
class TestStateReconciliationUnrecoverable(unittest.TestCase):
"""Tests for the unrecoverable-plugin cache and force reconcile.
Regression coverage for the infinite reinstall loop where a config
entry referenced a plugin not present in the registry (e.g. legacy
'github' / 'youtube' entries). The reconciler used to retry the
install on every HTTP request; it now caches the failure for the
process lifetime and only retries on an explicit ``force=True``
reconcile call.
"""
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp())
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
self.state_manager = Mock(spec=PluginStateManager)
self.state_manager.get_all_states.return_value = {}
self.config_manager = Mock()
self.config_manager.load_config.return_value = {
"ghost": {"enabled": True}
}
self.plugin_manager = Mock()
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Store manager with an empty registry — install_plugin always fails
self.store_manager = Mock()
self.store_manager.fetch_registry.return_value = {"plugins": []}
self.store_manager.install_plugin.return_value = False
self.store_manager.was_recently_uninstalled.return_value = False
# A bare Mock() returns a truthy Mock for is_plugin_uninstalled(),
# which reads as "persistently uninstalled" and skips auto-repair
# entirely — these tests need the repair path to run.
self.store_manager.is_plugin_uninstalled.return_value = False
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=self.store_manager,
)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_not_in_registry_marks_unrecoverable_without_install(self):
"""If the plugin isn't in the registry at all, skip install_plugin."""
result = self.reconciler.reconcile_state()
# One inconsistency, unfixable, no install attempt made.
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertEqual(len(result.inconsistencies_fixed), 0)
self.store_manager.install_plugin.assert_not_called()
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
def test_subsequent_reconcile_does_not_retry(self):
"""Second reconcile pass must not touch install_plugin or fetch_registry again."""
self.reconciler.reconcile_state()
self.store_manager.fetch_registry.reset_mock()
self.store_manager.install_plugin.reset_mock()
result = self.reconciler.reconcile_state()
# Still one inconsistency, still no install attempt, no new registry fetch
self.assertEqual(len(result.inconsistencies_found), 1)
inc = result.inconsistencies_found[0]
self.assertFalse(inc.can_auto_fix)
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
self.store_manager.install_plugin.assert_not_called()
self.store_manager.fetch_registry.assert_not_called()
def test_force_reconcile_clears_unrecoverable_cache(self):
"""force=True must re-attempt previously-failed plugins."""
self.reconciler.reconcile_state()
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
# Now pretend the registry gained the plugin so the pre-check passes
# and install_plugin is actually invoked.
self.store_manager.fetch_registry.return_value = {
"plugins": [{"id": "ghost"}]
}
self.store_manager.install_plugin.return_value = True
self.store_manager.install_plugin.reset_mock()
# Config still references ghost; disk still missing it — the
# reconciler should re-attempt install now that force=True cleared
# the cache. Use assert_called_once_with so a future regression
# that accidentally triggers a second install attempt on force=True
# is caught.
result = self.reconciler.reconcile_state(force=True)
self.store_manager.install_plugin.assert_called_once_with("ghost")
def test_registry_unreachable_does_not_mark_unrecoverable(self):
"""Transient registry failures should not poison the cache."""
self.store_manager.fetch_registry.side_effect = Exception("network down")
result = self.reconciler.reconcile_state()
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertNotIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
self.store_manager.install_plugin.assert_not_called()
def test_recently_uninstalled_skips_auto_repair(self):
"""A freshly-uninstalled plugin must not be resurrected by the reconciler."""
self.store_manager.was_recently_uninstalled.return_value = True
self.store_manager.fetch_registry.return_value = {
"plugins": [{"id": "ghost"}]
}
result = self.reconciler.reconcile_state()
self.assertEqual(len(result.inconsistencies_found), 1)
inc = result.inconsistencies_found[0]
self.assertFalse(inc.can_auto_fix)
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
self.store_manager.install_plugin.assert_not_called()
def test_real_store_manager_empty_registry_on_network_failure(self):
"""Regression: using the REAL PluginStoreManager (not a Mock), verify
the reconciler does NOT poison the unrecoverable cache when
``fetch_registry`` fails with no stale cache available.
Previously, the default stale-cache fallback in ``fetch_registry``
silently returned ``{"plugins": []}`` on network failure with no
cache. The reconciler's ``_auto_repair_missing_plugin`` saw "no
candidates in registry" and marked everything unrecoverable — a
regression that would bite every user doing a fresh boot on flaky
WiFi. The fix is ``fetch_registry(raise_on_failure=True)`` in
``_auto_repair_missing_plugin`` so the reconciler can tell a real
registry miss from a network error.
"""
from src.plugin_system.store_manager import PluginStoreManager
import requests as real_requests
real_store = PluginStoreManager(plugins_dir=str(self.plugins_dir))
real_store.registry_cache = None # fresh boot, no cache
real_store.registry_cache_time = None
# Stub the underlying HTTP so no real network call is made but the
# real fetch_registry code path runs.
real_store._http_get_with_retries = Mock(
side_effect=real_requests.ConnectionError("wifi down")
)
reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=real_store,
)
result = reconciler.reconcile_state()
# One inconsistency (ghost is in config, not on disk), but
# because the registry lookup failed transiently, we must NOT
# have marked it unrecoverable — a later reconcile (after the
# network comes back) can still auto-repair.
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertNotIn("ghost", reconciler._unrecoverable_missing_on_disk)
if __name__ == '__main__':
unittest.main()