864 lines
28 KiB
Python
864 lines
28 KiB
Python
"""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
|