import sys import importlib import queue import time from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace from unittest.mock import patch import pytest from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) import main from nostr.client import DEFAULT_RELAYS from seedpass.core.config_manager import ConfigManager from seedpass.core.manager import Notification, PasswordManager from seedpass.core.state_manager import StateManager from seedpass.core.vault import Vault from seedpass.core.errors import SeedPassError from utils.fingerprint_manager import FingerprintManager from utils.password_prompt import PasswordPromptError def setup_pm(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) import constants importlib.reload(constants) importlib.reload(main) fp_dir = constants.APP_DIR / "fp" fp_dir.mkdir(parents=True) vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, fp_dir) fp_mgr = FingerprintManager(constants.APP_DIR) nostr_stub = SimpleNamespace( relays=list(DEFAULT_RELAYS), close_client_pool=lambda: None, initialize_client_pool=lambda: None, publish_snapshot=lambda data, alt_summary=None: (None, "abcd"), key_manager=SimpleNamespace(get_npub=lambda: "npub"), ) pm = SimpleNamespace( config_manager=cfg_mgr, fingerprint_manager=fp_mgr, nostr_client=nostr_stub, ) return pm, cfg_mgr, fp_mgr def test_relay_and_profile_actions(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) # Add two fingerprints for listing fp1 = fp_mgr.add_fingerprint( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ) fp2 = fp_mgr.add_fingerprint( "legal winner thank year wave sausage worth useful legal winner thank yellow" ) # Add a relay with patch("builtins.input", return_value="wss://new"), patch( "main.handle_post_to_nostr" ), patch("main._reload_relays"): main.handle_add_relay(pm) cfg = cfg_mgr.load_config(require_pin=False) assert "wss://new" in cfg["relays"] # Remove the relay idx = cfg["relays"].index("wss://new") + 1 with patch("builtins.input", return_value=str(idx)), patch( "main._reload_relays" ): main.handle_remove_relay(pm) cfg = cfg_mgr.load_config(require_pin=False) assert "wss://new" not in cfg["relays"] # Reset to defaults with patch("main._reload_relays"): main.handle_reset_relays(pm) cfg = cfg_mgr.load_config(require_pin=False) assert cfg["relays"] == list(DEFAULT_RELAYS) # List profiles main.handle_list_fingerprints(pm) out = capsys.readouterr().out assert fp1 in out assert fp2 in out def test_settings_menu_additional_backup(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) inputs = iter(["10", ""]) with patch("main.handle_set_additional_backup_location") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) handler.assert_called_once_with(pm) def test_settings_menu_change_password(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) calls: list[tuple[str, str]] = [] pm.change_password = lambda old, new: calls.append((old, new)) inputs = iter(["3", ""]) monkeypatch.setattr(main, "prompt_existing_password", lambda *_: "oldpw") monkeypatch.setattr(main, "prompt_new_password", lambda *_: "newpw") monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) assert calls == [("oldpw", "newpw")] def test_settings_menu_change_password_incorrect(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) def fail_change(old, new): raise ValueError("Incorrect password") pm.change_password = fail_change inputs = iter(["3", ""]) monkeypatch.setattr(main, "prompt_existing_password", lambda *_: "badpw") monkeypatch.setattr(main, "prompt_new_password", lambda *_: "newpw") monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) out = capsys.readouterr().out assert "Incorrect password" in out def test_settings_menu_without_nostr_client(monkeypatch): pm = PasswordManager.__new__(PasswordManager) pm.offline_mode = False pm.nostr_client = None pm.notifications = queue.Queue() pm.error_queue = queue.Queue() pm.notify = lambda msg, level="INFO": pm.notifications.put(Notification(msg, level)) pm.is_dirty = False pm.last_update = time.time() pm.last_activity = time.time() pm.update_activity = lambda: None pm.lock_vault = lambda: None pm.unlock_vault = lambda: None pm.start_background_relay_check = lambda: None pm.poll_background_errors = PasswordManager.poll_background_errors.__get__(pm) pm.display_stats = lambda: None inputs = iter(["7", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: "") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) assert pm.error_queue.empty() assert pm.notifications.empty() def test_settings_menu_missing_handler_is_graceful(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) # Option 4 calls this method directly in settings. if hasattr(pm, "handle_verify_checksum"): delattr(pm, "handle_verify_checksum") inputs = iter(["4", ""]) monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) out = capsys.readouterr().out assert "Unexpected settings error:" in out def test_settings_lock_unlock_cancelled_is_graceful(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) pm.lock_vault = lambda: None def unlock_fail(): raise PasswordPromptError("Operation cancelled by user") pm.unlock_vault = unlock_fail pm.start_background_sync = lambda: None pm.start_background_relay_check = lambda: None inputs = iter(["13", ""]) monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) out = capsys.readouterr().out assert "Unlock cancelled: Operation cancelled by user" in out def test_profiles_submenu_action_error_is_graceful(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, fp_mgr = setup_pm(tmp_path, monkeypatch) pm.update_activity = lambda: None pm.handle_switch_fingerprint = lambda: (_ for _ in ()).throw( SeedPassError("switch failure") ) inputs = iter(["1", ""]) monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_profiles_menu(pm) out = capsys.readouterr().out assert "Action failed: switch failure" in out def test_settings_export_failure_message(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) pm.handle_export_database = lambda: (_ for _ in ()).throw( RuntimeError("export boom") ) inputs = iter(["7", ""]) monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) out = capsys.readouterr().out assert "Export failed: export boom" in out def test_settings_import_missing_path_message(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) pm.handle_import_database = lambda _path: (_ for _ in ()).throw( FileNotFoundError ) inputs = iter(["8", "missing.json.enc", ""]) monkeypatch.setattr(main, "pause", lambda: None) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) out = capsys.readouterr().out assert "Import failed: file 'missing.json.enc' not found." in out def test_reset_nostr_sync_state(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) fp_dir = tmp_path / ".seedpass" / "fp" state_mgr = StateManager(fp_dir) state_mgr.update_state( manifest_id="manifest-old", delta_since=123, last_sync_ts=456, nostr_account_idx=2, ) pm.state_manager = state_mgr pm.fingerprint_dir = fp_dir pm.current_fingerprint = "fp" pm.manifest_id = "manifest-old" pm.delta_since = 123 pm.last_sync_ts = 456 monkeypatch.setattr(main, "confirm_action", lambda *_: True) monkeypatch.setattr(main, "pause", lambda: None) main.handle_reset_nostr_sync_state(pm) state = state_mgr.state assert state["manifest_id"] is None assert state["delta_since"] == 0 assert state["last_sync_ts"] == 0 assert state["nostr_account_idx"] == 2 assert pm.manifest_id is None assert pm.delta_since == 0 assert pm.last_sync_ts == 0 assert pm.nostr_account_idx == 2 def test_start_fresh_nostr_namespace(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) fp_dir = tmp_path / ".seedpass" / "fp" state_mgr = StateManager(fp_dir) state_mgr.update_state( manifest_id="manifest-old", delta_since=123, last_sync_ts=456, nostr_account_idx=2, ) pm.state_manager = state_mgr pm.fingerprint_dir = fp_dir pm.current_fingerprint = "fp" pm.manifest_id = "manifest-old" pm.delta_since = 123 pm.last_sync_ts = 456 called = {"reinit": 0} pm._initialize_nostr_client = lambda: called.__setitem__( "reinit", called["reinit"] + 1 ) monkeypatch.setattr(main, "confirm_action", lambda *_: True) monkeypatch.setattr(main, "pause", lambda: None) main.handle_start_fresh_nostr_namespace(pm) state = state_mgr.state assert state["manifest_id"] is None assert state["delta_since"] == 0 assert state["last_sync_ts"] == 0 assert state["nostr_account_idx"] == 3 assert pm.nostr_account_idx == 3 assert called["reinit"] == 1 def test_nostr_menu_dispatches_reset_sync_state(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) pm.update_activity = lambda: None inputs = iter(["8", ""]) with patch("main.handle_reset_nostr_sync_state") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_nostr_menu(pm) handler.assert_called_once_with(pm) def test_nostr_menu_dispatches_fresh_namespace(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) pm.update_activity = lambda: None inputs = iter(["9", ""]) with patch("main.handle_start_fresh_nostr_namespace") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_nostr_menu(pm) handler.assert_called_once_with(pm) def test_settings_menu_dispatches_semantic_submenu(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) inputs = iter(["18", ""]) with patch("main.handle_semantic_index_menu") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) handler.assert_called_once_with(pm) def test_semantic_submenu_search_flow(monkeypatch, capsys): class DummySemanticService: def __init__(self, _pm): self.last_query = None self.mode = "keyword" def status(self): return {"enabled": False, "built": False, "records": 0, "mode": self.mode} def set_enabled(self, enabled: bool): return {"enabled": bool(enabled), "records": 0} def build(self): return {"enabled": True, "built": True, "records": 2} def rebuild(self): return {"enabled": True, "built": True, "records": 2} def set_mode(self, mode: str): self.mode = str(mode) return {"mode": self.mode} def search(self, query: str, *, k: int = 10, kind: str | None = None): self.last_query = query return [{"entry_id": 1, "kind": "document", "label": "Doc", "score": 0.5}] with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) pm, _, _ = setup_pm(tmp_path, monkeypatch) monkeypatch.setattr(main, "SemanticIndexService", DummySemanticService) monkeypatch.setattr(main, "pause", lambda: None) inputs = iter(["1", "7", "hybrid", "2", "4", "6", "relay docs", ""]) with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_semantic_index_menu(pm) out = capsys.readouterr().out assert "Status: enabled=False, built=False, records=0, mode=keyword" in out assert "Semantic search mode set to hybrid." in out assert "Semantic index enabled" in out assert "Semantic index built with 2 records" in out assert "Semantic Matches:" in out assert "#1 [document] Doc" in out