chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,863 @@
|
||||
"""Headless `onboard`/`configure` CLI semantics.
|
||||
|
||||
Pins the gate-flag error contract (explicit-but-incomplete flag sets exit 2),
|
||||
keep-current re-saves, cancellation and config-error productization, restart
|
||||
guidance, the voice-audio catalog section, and strict `--field` coercion.
|
||||
All tests are offline and use synthetic dummy data only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.config_store import load_config
|
||||
|
||||
runner = CliRunner()
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _plain(value: str) -> str:
|
||||
return " ".join(_ANSI_RE.sub("", value).split())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-1: explicit-but-incomplete headless flag combinations must exit 2 and
|
||||
# name the missing gate flag instead of silently no-opping with exit 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "missing_flag"),
|
||||
[
|
||||
(["configure", "router", "--default-tier", "c2"], "--router"),
|
||||
(["configure", "provider", "--model", "some/model"], "--provider"),
|
||||
(["configure", "search", "--max-results", "9"], "--search-provider"),
|
||||
(["configure", "channels", "--channel-type", "slack"], "--name"),
|
||||
(["configure", "channels", "--name", "work"], "--channel-type"),
|
||||
(["configure", "image", "--primary", "openrouter/x"], "--image-provider"),
|
||||
(["configure", "memory", "--onnx-dir", "models/bge"], "--memory-provider"),
|
||||
],
|
||||
)
|
||||
def test_configure_incomplete_flags_exit_2_naming_missing_gate(
|
||||
tmp_path, monkeypatch, args, missing_flag
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", *args])
|
||||
|
||||
assert result.exit_code == 2, result.output
|
||||
assert missing_flag in _plain(result.stderr)
|
||||
assert "no changes were made" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_router_incomplete_flags_do_not_touch_existing_config(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
before = target.read_text(encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "router", "--default-tier", "c2"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert target.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_configure_flags_without_section_exit_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "--router", "recommended"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "target section" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_unknown_section_exits_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "not-a-section"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "unknown configure section" in _plain(result.stderr)
|
||||
assert "not-a-section" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_audio_section_exits_2_with_catalog_pointer(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "audio"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "opensquilla onboard catalog audio" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_bare_non_tty_exits_2_with_hint(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "requires a TTY" in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-2: `onboard catalog audio` must render usable output, and the overview
|
||||
# must not leak the raw audioProviders key.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_catalog_audio_prints_provider_rows(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog", "audio"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert result.stdout.strip()
|
||||
assert "voice audio provider options" in result.stdout
|
||||
assert "elevenlabs" in result.stdout
|
||||
assert "ELEVENLABS_API_KEY" in result.stdout
|
||||
assert "Try:" in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_onboard_catalog_overview_names_voice_audio_without_raw_key(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Voice audio providers" in result.stdout
|
||||
assert "audioProviders" not in result.stdout
|
||||
compact = "".join(result.stdout.split())
|
||||
assert "opensquillaonboardcatalogaudio" in compact
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-3: an explicitly stored enabled=false must survive an image key rotation
|
||||
# that omits --image-enabled/--no-image-enabled.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_image_rotation_keeps_deliberate_disabled_state(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n'
|
||||
"\n"
|
||||
"[image_generation]\n"
|
||||
"enabled = false\n"
|
||||
'primary = "openrouter/google/gemini-3.1-flash-image-preview"\n'
|
||||
"\n"
|
||||
"[image_generation.providers.openrouter]\n"
|
||||
'api_key_env = "OPENSQUILLA_TEST_IMAGE_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_TEST_IMAGE_KEY", "sk-image-env")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"image",
|
||||
"--image-provider",
|
||||
"openrouter",
|
||||
"--primary",
|
||||
"openrouter/google/gemini-3.1-flash-image-preview",
|
||||
"--api-key-env",
|
||||
"OPENSQUILLA_TEST_IMAGE_KEY",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config(target).image_generation.enabled is False
|
||||
|
||||
|
||||
def test_configure_image_rotation_keeps_enabled_true(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[image_generation]\n"
|
||||
"enabled = true\n"
|
||||
'primary = "openrouter/google/gemini-3.1-flash-image-preview"\n'
|
||||
"\n"
|
||||
"[image_generation.providers.openrouter]\n"
|
||||
'api_key_env = "OPENSQUILLA_TEST_IMAGE_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_TEST_IMAGE_KEY", "sk-image-env")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"image",
|
||||
"--image-provider",
|
||||
"openrouter",
|
||||
"--primary",
|
||||
"openrouter/google/gemini-3.1-flash-image-preview",
|
||||
"--api-key-env",
|
||||
"OPENSQUILLA_TEST_IMAGE_KEY",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config(target).image_generation.enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-4: key rotation must not clobber stored provider/search settings when
|
||||
# the corresponding flags are omitted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_provider_key_rotation_keeps_model_base_url_and_proxy(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
'base_url = "https://gateway.example.test/v1"\n'
|
||||
'proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"provider",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.base_url == "https://gateway.example.test/v1"
|
||||
assert cfg.llm.proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_keeps_base_url_and_proxy(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# The top-level `onboard --provider` path must honor the same
|
||||
# keep-current contract as `configure provider`: an omitted --router
|
||||
# never re-applies a router profile on a configured install, so the
|
||||
# stored model and endpoint settings all survive a key rotation.
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
'base_url = "https://gateway.example.test/v1"\n'
|
||||
'proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk-new", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.base_url == "https://gateway.example.test/v1"
|
||||
assert cfg.llm.proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_with_router_disabled_keeps_model(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
"--router",
|
||||
"disabled",
|
||||
"--minimal",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_configure_search_key_rotation_keeps_stored_global_settings(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'search_provider = "brave"\n'
|
||||
'search_api_key = "sk-old"\n'
|
||||
"search_max_results = 9\n"
|
||||
'search_proxy = "http://127.0.0.1:7890"\n'
|
||||
"search_use_env_proxy = true\n"
|
||||
'search_fallback_policy = "network"\n'
|
||||
"search_diagnostics = true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"search",
|
||||
"--search-provider",
|
||||
"brave",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.search_max_results == 9
|
||||
assert cfg.search_proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.search_use_env_proxy is True
|
||||
assert cfg.search_fallback_policy == "network"
|
||||
assert cfg.search_diagnostics is True
|
||||
assert cfg.search_api_key == "sk-new"
|
||||
|
||||
|
||||
def test_configure_search_explicit_flags_still_override_stored(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'search_provider = "duckduckgo"\n'
|
||||
"search_max_results = 9\n"
|
||||
'search_proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"search",
|
||||
"--search-provider",
|
||||
"duckduckgo",
|
||||
"--max-results",
|
||||
"3",
|
||||
"--proxy",
|
||||
"",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.search_max_results == 3
|
||||
assert cfg.search_proxy == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-5: a wizard cancellation (Esc/Ctrl+C) must exit with one short line and
|
||||
# code 130 instead of a raw traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_wizard_cancellation_is_productized(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.errors import UserCancelledError
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def cancelled(_options):
|
||||
raise UserCancelledError(section="provider")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", cancelled)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_wizard_cancellation_is_productized(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.errors import UserCancelledError
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def cancelled(_section, *, config_path=None):
|
||||
raise UserCancelledError(section="configure")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_configure", cancelled)
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "router"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-6: bare `onboard` with a corrupt config must route through the
|
||||
# productized config-error handoff (exit 2), never a raw traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bare_onboard_toml_decode_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("not toml :::", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_bare_onboard_validation_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("[search]\n", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "pydantic_core" not in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_bare_onboard_os_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "config-dir.toml"
|
||||
target.mkdir() # opening a directory raises IsADirectoryError (OSError)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_onboard_provider_with_corrupt_config_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("not toml :::", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-7: a ValidationError on the `onboard --provider` path must not echo
|
||||
# pydantic's input_value (which can carry a mispasted secret).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_provider_validation_error_never_echoes_input_value(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
class _Probe(BaseModel):
|
||||
max_tokens: int
|
||||
|
||||
try:
|
||||
_Probe(max_tokens="sk-super-secret-value") # type: ignore[arg-type]
|
||||
except ValidationError as exc:
|
||||
captured = exc
|
||||
else: # pragma: no cover - the construction above always fails
|
||||
raise AssertionError("expected a ValidationError")
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
class _RaisingEngine:
|
||||
def __init__(self, *_a, **_kw):
|
||||
pass
|
||||
|
||||
def apply(self, *_a, **_kw):
|
||||
raise captured
|
||||
|
||||
def persist(self, *_a, **_kw): # pragma: no cover - apply raises first
|
||||
raise AssertionError("persist must not run after a failed apply")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "SetupEngine", _RaisingEngine)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.output + result.stderr
|
||||
assert "sk-super-secret-value" not in combined
|
||||
assert "input_value" not in combined
|
||||
assert "max_tokens" in _plain(result.stderr)
|
||||
assert "Traceback" not in combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-8: headless channels/memory saves must surface PersistResult's
|
||||
# restart_required instead of printing only the saved path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_channels_prints_restart_guidance(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"slack",
|
||||
"--name",
|
||||
"work",
|
||||
"--token",
|
||||
"xoxb-secret",
|
||||
"--field",
|
||||
"signing_secret=ss",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = _plain(result.stdout)
|
||||
assert "restart required" in plain
|
||||
assert "opensquilla gateway restart" in plain
|
||||
|
||||
|
||||
def test_configure_memory_prints_restart_guidance_with_config_path(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
default_target = tmp_path / "default.toml"
|
||||
target = tmp_path / "custom.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"memory",
|
||||
"--memory-provider",
|
||||
"local",
|
||||
"--onnx-dir",
|
||||
"models/bge",
|
||||
"--config",
|
||||
str(target),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = _plain(result.stdout)
|
||||
assert "restart required" in plain
|
||||
assert "opensquilla gateway restart" in plain
|
||||
assert str(target) in plain
|
||||
assert not default_target.exists()
|
||||
|
||||
|
||||
def test_configure_search_does_not_print_restart_guidance(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "configure", "search", "--search-provider", "duckduckgo"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "restart required" not in _plain(result.stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-10: strict --field coercion (bool typos and numeric garbage must name
|
||||
# the offending field and the accepted spellings).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_channels_field_bool_typo_exits_2_naming_field(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"slack",
|
||||
"--name",
|
||||
"work",
|
||||
"--token",
|
||||
"xoxb-secret",
|
||||
"--field",
|
||||
"signing_secret=ss",
|
||||
"--field",
|
||||
"enabled=ture",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
plain = _plain(result.output + result.stderr)
|
||||
assert "enabled" in plain
|
||||
assert "true/false" in plain
|
||||
assert "'ture'" in plain
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_channels_field_int_garbage_exits_2_naming_field(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"discord",
|
||||
"--name",
|
||||
"guild",
|
||||
"--token",
|
||||
"bot-secret",
|
||||
"--field",
|
||||
"intents=lots",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
plain = _plain(result.output + result.stderr)
|
||||
assert "intents" in plain
|
||||
assert "integer" in plain
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_parse_channel_field_pairs_strict_coercion_unit():
|
||||
import typer
|
||||
|
||||
from opensquilla.cli.channel_fields import parse_channel_field_pairs
|
||||
|
||||
assert parse_channel_field_pairs(["enabled=TRUE"], "slack")["enabled"] is True
|
||||
assert parse_channel_field_pairs(["enabled=off"], "slack")["enabled"] is False
|
||||
|
||||
with pytest.raises(typer.BadParameter, match=r"--field enabled .*'ture'"):
|
||||
parse_channel_field_pairs(["enabled=ture"], "slack")
|
||||
with pytest.raises(typer.BadParameter, match=r"--field intents .*integer"):
|
||||
parse_channel_field_pairs(["intents=lots"], "discord")
|
||||
with pytest.raises(typer.BadParameter, match=r"--field poll_idle_sleep_s .*number"):
|
||||
parse_channel_field_pairs(["poll_idle_sleep_s=fast"], "telegram")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-11: previously untested branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_probe_flag_exits_nonzero_when_probe_raises(tmp_path, monkeypatch):
|
||||
from opensquilla.onboarding import probe as probe_module
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
async def exploding_probe(**_kwargs):
|
||||
raise RuntimeError("socket exploded mid-flight")
|
||||
|
||||
monkeypatch.setattr(probe_module, "probe_llm_provider", exploding_probe)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--model",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"--api-key",
|
||||
"sk",
|
||||
"--minimal",
|
||||
"--probe",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Probe failed" in result.stderr
|
||||
assert "socket exploded mid-flight" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
# The probe is a gate, not a rollback: the save sticks.
|
||||
assert "openrouter" in target.read_text()
|
||||
|
||||
|
||||
def test_onboard_catalog_unknown_section_exits_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog", "not-a-section"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "unknown setup section" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_onboard_if_needed_names_unfinished_sections(tmp_path, monkeypatch):
|
||||
# has_config=True but the referenced env key is missing: the gate must
|
||||
# explain which sections are unfinished before falling into the wizard.
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "deepseek/deepseek-v4-flash"\n'
|
||||
'api_key_env = "CUSTOM_LLM_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("CUSTOM_LLM_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "--if-needed"])
|
||||
|
||||
assert result.exit_code == 2 # non-TTY still exits 2 afterwards
|
||||
assert "onboarding has unfinished sections" in result.stdout
|
||||
assert "Provider" in result.stdout
|
||||
|
||||
|
||||
def test_bare_configure_hub_exit_without_changes_is_success(tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
from opensquilla.onboarding import flow
|
||||
|
||||
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
||||
|
||||
class _Answer:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def ask(self):
|
||||
return self.value
|
||||
|
||||
class _Questionary(types.SimpleNamespace):
|
||||
def select(self, message: str, **kwargs):
|
||||
assert message == "Section"
|
||||
assert kwargs["choices"][-1] == "Exit (nothing changed)"
|
||||
return _Answer("Exit (nothing changed)")
|
||||
|
||||
def text(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected text prompt: {message}")
|
||||
|
||||
def confirm(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected confirm prompt: {message}")
|
||||
|
||||
def password(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected password prompt: {message}")
|
||||
|
||||
def checkbox(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
||||
|
||||
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "requires a TTY" not in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-9: router tier ids in the CLI stay sourced from the router_tiers
|
||||
# helpers instead of raw tuples/literals.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_router_catalog_tracks_router_tier_helpers(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.router_tiers import DEFAULT_TEXT_TIER, TEXT_TIERS
|
||||
|
||||
assert (
|
||||
f"--default-tier {DEFAULT_TEXT_TIER}"
|
||||
in onboard_cmd._CATALOG_COMMANDS["routerProfiles"]
|
||||
)
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
result = runner.invoke(app, ["onboard", "catalog", "router"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
for tier in TEXT_TIERS:
|
||||
assert f"{tier}:" in result.stdout
|
||||
Reference in New Issue
Block a user