"""Input validation and stored-value seeding in the interactive wizard. These tests drive the questionary seams with a validate-aware fake prompt: like the real library, a canned input is only returned once the prompt's ``validate=`` hook accepts it, so garbage input re-prompts instead of leaking into ``int()``/pydantic and crashing the wizard mid-section. """ from __future__ import annotations import sys import types from typing import Any import pytest from opensquilla.onboarding import flow from opensquilla.onboarding.channel_specs import ChannelSetupField from opensquilla.onboarding.errors import UserCancelledError from opensquilla.onboarding.flow import OnboardOptions from opensquilla.onboarding.provider_specs import get_provider_setup_spec from opensquilla.onboarding.search_specs import get_search_provider_setup_spec from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS class _ValidatedPrompt: """Fake prompt honouring ``validate=`` the way questionary does. Rejected input is re-asked (the next canned value is tried), so a garbage value can only be returned when the prompt carries no validator — which is exactly the regression each test pins. """ def __init__(self, values: list[Any], validate: Any) -> None: self._values = list(values) self._validate = validate def ask(self) -> Any: while self._values: candidate = self._values.pop(0) if candidate is None: return None # cancel short-circuits validation if self._validate is None or self._validate(candidate) is True: return candidate raise AssertionError("prompt inputs exhausted without an accepted value") class _RecordingConsole: def __init__(self) -> None: self.messages: list[str] = [] def print(self, message: str = "", *_a, **_kw) -> None: self.messages.append(str(message)) def joined(self) -> str: return "\n".join(self.messages) # --------------------------------------------------------------------------- # C1-5 — numeric prompts re-prompt on garbage instead of raising ValueError. # --------------------------------------------------------------------------- def _duckduckgo_search_questionary(max_results_inputs: list[Any]): class _Answer: def __init__(self, value: Any) -> None: self.value = value def ask(self) -> Any: return self.value class _Questionary: def text(self, message: str, **kwargs: Any): if message == "Max search results": return _ValidatedPrompt(max_results_inputs, kwargs.get("validate")) if message == "Search HTTP proxy": return _Answer("") raise AssertionError(f"unexpected text prompt: {message}") def select(self, message: str, **kwargs: Any): if message == "Search fallback policy": return _Answer(kwargs.get("default")) raise AssertionError(f"unexpected select prompt: {message}") def confirm(self, message: str, **_kwargs: Any): return _Answer(False) def password(self, message: str, **_kwargs: Any): raise AssertionError(f"unexpected password prompt: {message}") return _Questionary() def test_search_max_results_garbage_reprompts_instead_of_crashing(): """Typing a non-number used to raise a raw ``ValueError`` that aborted every remaining onboarding section and discarded the just-entered search credentials.""" answers = flow._ask_search_fields( _duckduckgo_search_questionary(["banana", "7"]), get_search_provider_setup_spec("duckduckgo"), ) assert answers["max_results"] == 7 def test_search_max_results_blank_keeps_default(): answers = flow._ask_search_fields( _duckduckgo_search_questionary([""]), get_search_provider_setup_spec("duckduckgo"), ) assert answers["max_results"] == DEFAULT_SEARCH_MAX_RESULTS def test_search_max_results_validator_enforces_minimum_of_one(): """The write side requires an integer >= 1; the prompt must reject the same range up front instead of failing at persist time.""" answers = flow._ask_search_fields( _duckduckgo_search_questionary(["0", "-3", "2"]), get_search_provider_setup_spec("duckduckgo"), ) assert answers["max_results"] == 2 def test_channel_int_field_garbage_reprompts_instead_of_crashing(): field = ChannelSetupField( name="poll_interval", label="Poll interval", field_type="int", required=True, default=3, ) class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Poll interval" return _ValidatedPrompt(["lots", "5"], kwargs.get("validate")) assert flow._ask_channel_field(_Questionary(), field, field.default) == 5 def test_channel_float_field_garbage_reprompts_instead_of_crashing(): field = ChannelSetupField( name="timeout", label="Timeout seconds", field_type="float", required=True, default=1.5, ) class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Timeout seconds" return _ValidatedPrompt(["fast", "2.5"], kwargs.get("validate")) assert flow._ask_channel_field(_Questionary(), field, field.default) == 2.5 # --------------------------------------------------------------------------- # C1-6 — required base URL: validated input, Esc cancels. # --------------------------------------------------------------------------- def test_required_base_url_rejects_blank_and_non_http_schemes(): """Plain Enter used to coerce the answer to '' and kill the wizard with a raw ``ValueError`` at persist time.""" captured: dict[str, Any] = {} class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Base URL" captured["validate"] = kwargs.get("validate") return _ValidatedPrompt( ["", "ftp://intranet.test", "http://localhost:8000/v1"], kwargs.get("validate"), ) answers = flow._ask_provider_fields( _Questionary(), get_provider_setup_spec("vllm"), OnboardOptions(model="stub-model"), ) assert answers["base_url"] == "http://localhost:8000/v1" validate = captured["validate"] assert validate is not None assert validate("") is not True assert validate(" ") is not True assert validate("ftp://intranet.test") is not True assert validate("https://example.test/v1") is True def test_required_base_url_cancel_raises_user_cancelled(): class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Base URL" return _ValidatedPrompt([None], kwargs.get("validate")) with pytest.raises(UserCancelledError) as exc_info: flow._ask_provider_fields( _Questionary(), get_provider_setup_spec("vllm"), OnboardOptions(model="stub-model"), ) assert exc_info.value.section == "provider" # --------------------------------------------------------------------------- # C1-7 — wizard re-run seeds prompt defaults from the stored provider entry. # --------------------------------------------------------------------------- def _stored_config(provider: str, *, base_url: str, proxy: str) -> Any: return types.SimpleNamespace( llm=types.SimpleNamespace(provider=provider, base_url=base_url, proxy=proxy) ) def test_provider_rerun_seeds_base_url_prompt_and_keeps_proxy(): """Re-running the wizard for an already-stored provider must default the Base URL prompt to the stored endpoint and keep the stored proxy — accepting the defaults used to silently resave the spec defaults.""" captured: dict[str, Any] = {} class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Base URL" captured["default"] = kwargs.get("default") return _ValidatedPrompt([kwargs.get("default")], kwargs.get("validate")) answers = flow._ask_provider_fields( _Questionary(), get_provider_setup_spec("vllm"), OnboardOptions(model="stub-model"), config=_stored_config( "vllm", base_url="http://intranet.test:8000/v1", proxy="http://proxy.test:3128", ), ) assert captured["default"] == "http://intranet.test:8000/v1" assert answers["base_url"] == "http://intranet.test:8000/v1" assert answers["proxy"] == "http://proxy.test:3128" def test_provider_rerun_keeps_stored_custom_base_url_without_prompt(monkeypatch): """Providers that do not prompt for a base URL must still keep a stored custom endpoint and proxy on re-run instead of resaving spec defaults.""" monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None) class _Questionary: def text(self, message: str, **_kwargs: Any): raise AssertionError(f"unexpected text prompt: {message}") answers = flow._ask_provider_fields( _Questionary(), get_provider_setup_spec("openrouter"), OnboardOptions(api_key_env="OPENROUTER_API_KEY"), config=_stored_config( "openrouter", base_url="https://relay.internal.test/v1", proxy="http://proxy.test:3128", ), ) assert answers["base_url"] == "https://relay.internal.test/v1" assert answers["proxy"] == "http://proxy.test:3128" def test_provider_switch_ignores_other_providers_stored_endpoint(monkeypatch): """Stored values belong to the stored provider only — selecting a different provider must fall back to that provider's spec defaults.""" monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None) class _Questionary: def text(self, message: str, **_kwargs: Any): raise AssertionError(f"unexpected text prompt: {message}") spec = get_provider_setup_spec("openrouter") answers = flow._ask_provider_fields( _Questionary(), spec, OnboardOptions(api_key_env="OPENROUTER_API_KEY"), config=_stored_config( "groq", base_url="https://groq-relay.internal.test/v1", proxy="http://proxy.test:3128", ), ) assert answers["base_url"] == spec.default_base_url assert answers["proxy"] == "" # --------------------------------------------------------------------------- # C1-8 — channel edit: blank keeps the stored secret. # --------------------------------------------------------------------------- def test_channel_secret_with_stored_value_allows_blank_and_hints(monkeypatch): recorder = _RecordingConsole() monkeypatch.setattr(flow, "console", recorder) field = ChannelSetupField( name="token", label="Bot token", field_type="password", required=True, secret=True, ) captured: dict[str, Any] = {} class _Questionary: def password(self, message: str, **kwargs: Any): assert message == "Bot token" captured["validate"] = kwargs.get("validate") return _ValidatedPrompt([""], kwargs.get("validate")) result = flow._ask_channel_field(_Questionary(), field, "xoxb-stored") # Blank resolves to the stored value at the prompt itself (not via the # mutation's by-name merge), so the keep promise also holds when the # operator renames the entry in the same edit. assert result == "xoxb-stored" validate = captured["validate"] assert validate is not None assert validate("") is True # Broken terminal pastes are still rejected even in keep-current mode. assert validate("\x1b[200~xoxb-new\x1b[201~") is not True assert "leave blank to keep the stored value" in recorder.joined() def test_channel_secret_without_stored_value_still_requires_input(): field = ChannelSetupField( name="token", label="Bot token", field_type="password", required=True, secret=True, ) captured: dict[str, Any] = {} class _Questionary: def password(self, message: str, **kwargs: Any): captured["validate"] = kwargs.get("validate") return _ValidatedPrompt(["", "xoxb-new"], kwargs.get("validate")) result = flow._ask_channel_field(_Questionary(), field, None) assert result == "xoxb-new" assert captured["validate"]("") is not True def test_channel_edit_blank_secrets_keep_stored_values(tmp_path, monkeypatch): """End-to-end edit: leaving both secret prompts blank must round-trip the stored credentials through the mutation layer's keep-current merge.""" target = tmp_path / "c.toml" from opensquilla.onboarding.setup_engine import SetupEngine engine = SetupEngine(path=target) engine.apply( "channels", { "type": "slack", "name": "slack-main", "token": "xoxb-stored", "signing_secret": "ss-stored", }, ) engine.persist() monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "console", _RecordingConsole()) passwords: list[str] = [] class _Answer: def __init__(self, value: Any) -> None: self.value = value def ask(self) -> Any: return self.value class _Questionary(types.SimpleNamespace): def select(self, message: str, **kwargs: Any): if message == "Channel to edit": return _Answer("slack-main") return _Answer(kwargs.get("default") or list(kwargs.get("choices") or [])[0]) def text(self, message: str, **kwargs: Any): return _ValidatedPrompt([kwargs.get("default", "")], kwargs.get("validate")) def password(self, message: str, **kwargs: Any): passwords.append(message) return _ValidatedPrompt([""], kwargs.get("validate")) def confirm(self, message: str, **kwargs: Any): return _Answer(bool(kwargs.get("default"))) def checkbox(self, message: str, **_kwargs: Any): raise AssertionError(f"unexpected checkbox prompt: {message}") monkeypatch.setitem(sys.modules, "questionary", _Questionary()) flow.run_interactive_channel_edit(None, config_path=target) assert passwords, "the edit flow should have prompted for at least one secret" data = target.read_text() assert 'token = "xoxb-stored"' in data assert 'signing_secret = "ss-stored"' in data # --------------------------------------------------------------------------- # C1-9 — free-text model prompt must reject a blank submit (R9). # --------------------------------------------------------------------------- def test_free_text_model_prompt_rejects_blank_then_accepts_model(): """Providers without a derivable default model raise ValueError('model is required') deep in the mutation after the operator already typed the API key — the model prompt must re-ask on a blank submit instead.""" captured: dict[str, Any] = {} class _Questionary: def text(self, message: str, **kwargs: Any): assert message == "Model id" captured["validate"] = kwargs.get("validate") return _ValidatedPrompt(["", " ", "my-model"], kwargs.get("validate")) result = flow._prompt_free_text_model(_Questionary()) assert result == "my-model" validate = captured["validate"] assert validate is not None assert validate("") is not True assert validate(" ") is not True assert validate("my-model") is True def test_free_text_model_prompt_cancel_raises_user_cancelled(): class _Questionary: def text(self, message: str, **kwargs: Any): return _ValidatedPrompt([None], kwargs.get("validate")) with pytest.raises(UserCancelledError) as exc_info: flow._prompt_free_text_model(_Questionary()) assert exc_info.value.section == "provider" # --------------------------------------------------------------------------- # Wizard numeric channel fields share the headless --field coercer (F32). # --------------------------------------------------------------------------- def test_channel_int_field_blank_keeps_the_displayed_default(): """Clearing the pre-filled default must keep it — the wizard twin of the headless contract where omitting --field keeps the stored/spec value. The old hand-rolled coercion silently turned a blank answer into 0.""" field = ChannelSetupField( name="poll_timeout_s", label="Polling timeout (s)", field_type="int", required=False, default=30, ) class _Questionary: def text(self, message: str, **kwargs: Any): return _ValidatedPrompt([""], kwargs.get("validate")) assert flow._ask_channel_field(_Questionary(), field, field.default) == 30 def test_channel_int_field_agrees_with_headless_coercion_semantics(): """The wizard validator and the headless --field parser must accept and reject exactly the same spellings for the same spec field.""" from opensquilla.cli.channel_fields import coerce_channel_field_value field = ChannelSetupField( name="intents", label="Intents bitmask", field_type="int", required=False, default=0, ) validate = flow._channel_number_validator(field) import typer for raw in ("5", "-3", " 12 "): assert validate(raw) is True expected = coerce_channel_field_value("int", raw.strip(), field_name="intents") value, error = flow._coerce_channel_prompt_value(field, raw.strip()) assert error is None assert value == expected for raw in ("abc", "7.5", "0x10"): wizard_verdict = validate(raw) assert wizard_verdict is not True with pytest.raises(typer.BadParameter) as exc_info: coerce_channel_field_value("int", raw, field_name="intents") # Same wording too, with the flag swapped for the field label the # wizard user actually sees. assert wizard_verdict == str(exc_info.value.message).replace( "--field intents", "Intents bitmask" ) def test_channel_int_field_error_names_the_label_not_the_flag(): """The wizard user never typed --field: the re-prompt message must carry the field label while keeping the shared coercer's wording.""" field = ChannelSetupField( name="intents", label="Intents bitmask", field_type="int", required=False, default=0, ) verdict = flow._channel_number_validator(field)("banana") assert isinstance(verdict, str) assert "Intents bitmask" in verdict assert "--field" not in verdict assert "expects an integer" in verdict def test_channel_float_field_agrees_with_headless_coercion_semantics(): import typer from opensquilla.cli.channel_fields import coerce_channel_field_value field = ChannelSetupField( name="poll_idle_sleep_s", label="Poll idle sleep (s)", field_type="float", required=False, default=1.5, ) class _Questionary: def text(self, message: str, **kwargs: Any): return _ValidatedPrompt(["not-a-number", "2.5"], kwargs.get("validate")) value = flow._ask_channel_field(_Questionary(), field, field.default) assert value == coerce_channel_field_value("float", "2.5", field_name=field.name) assert isinstance(value, float) # Rejection wording stays in lockstep with the headless parser as well. wizard_verdict = flow._channel_number_validator(field)("not-a-number") with pytest.raises(typer.BadParameter) as exc_info: coerce_channel_field_value("float", "not-a-number", field_name=field.name) assert wizard_verdict == str(exc_info.value.message).replace( f"--field {field.name}", field.label )