import importlib import json from pathlib import Path from typing import Any from fastapi.testclient import TestClient import app as babble_app class FakeTelegramClient: requests: list[dict[str, Any]] = [] status_code = 200 text = "{\"ok\":true}" def __init__(self, *args: Any, **kwargs: Any) -> None: del args, kwargs async def __aenter__(self) -> "FakeTelegramClient": return self async def __aexit__(self, *args: Any) -> None: del args async def post(self, url: str, json: dict[str, Any]) -> "FakeTelegramClient": self.requests.append({"url": url, "json": json}) return self class TimeoutTelegramClient(FakeTelegramClient): async def post(self, url: str, json: dict[str, Any]) -> "FakeTelegramClient": del url, json raise babble_app.httpx.ConnectTimeout("timeout", request=None) def load_app(monkeypatch, **env): monkeypatch.setenv("REQUIRE_MODEL", "false") monkeypatch.setenv("MEMORY_ENABLED", "false") for key, value in env.items(): monkeypatch.setenv(key, value) return importlib.reload(babble_app) def configure_webhook(monkeypatch, **env): reloaded_app = load_app( monkeypatch, WEBHOOK_SECRET="expected-secret", TELEGRAM_BOT_TOKEN="fake-token", LLM_PROVIDER="stub", **env, ) FakeTelegramClient.requests = [] monkeypatch.setattr(reloaded_app.httpx, "AsyncClient", FakeTelegramClient) return reloaded_app def test_health_endpoint(monkeypatch) -> None: reloaded_app = load_app(monkeypatch, LLM_PROVIDER="stub") client = TestClient(reloaded_app.create_app()) response = client.get("/health") assert response.status_code == 200 body = response.json() assert body["ok"] is True assert body["service"] == "babble" assert body["model"] == "Qwen/Qwen3-0.6B" assert body["loaded"] is False assert body["persona"] == "default" def test_persona_endpoint_lists_available_personas(monkeypatch) -> None: reloaded_app = load_app(monkeypatch, LLM_PROVIDER="stub") client = TestClient(reloaded_app.create_app()) response = client.get("/persona") assert response.status_code == 200 body = response.json() assert body["active_persona"]["persona_id"] == "default" assert body["active_persona"]["name"] == "Babble" assert "playful" in body["available_personas"] def test_menu_endpoint_shows_commands(monkeypatch) -> None: reloaded_app = load_app(monkeypatch, LLM_PROVIDER="stub") client = TestClient(reloaded_app.create_app()) response = client.get("/menu") assert response.status_code == 200 text = response.json()["text"] assert "/persona [id]" in text assert "/character key=value ..." in text assert "Available personas" in text def test_webhook_rejects_wrong_secret(monkeypatch) -> None: reloaded_app = configure_webhook(monkeypatch) client = TestClient(reloaded_app.create_app()) response = client.post( "/telegram/webhook/wrong-secret", json={"message": {"chat": {"id": 123}, "text": "hello"}}, ) assert response.status_code == 403 assert FakeTelegramClient.requests == [] def test_start_sends_telegram_message_payload(monkeypatch) -> None: reloaded_app = configure_webhook(monkeypatch) client = TestClient(reloaded_app.create_app()) response = client.post( "/telegram/webhook/expected-secret", json={"message": {"chat": {"id": 123}, "text": " /start "}}, ) assert response.status_code == 200 assert response.json() == {"ok": True, "sent": True} request = FakeTelegramClient.requests[-1] assert request["url"] == "https://api.telegram.org/botfake-token/sendMessage" assert request["json"]["chat_id"] == 123 assert request["json"]["text"] == "Hey, I’m here. What kind of mood are we in tonight?" assert "reply_markup" in request["json"] assert request["json"]["reply_markup"]["keyboard"][0] == [ "Menu", "Characters", "Build Character", ] def test_telegram_timeout_falls_back_to_webhook_payload(monkeypatch) -> None: reloaded_app = load_app( monkeypatch, WEBHOOK_SECRET="expected-secret", TELEGRAM_BOT_TOKEN="fake-token", LLM_PROVIDER="stub", ) monkeypatch.setattr(reloaded_app.httpx, "AsyncClient", TimeoutTelegramClient) client = TestClient(reloaded_app.create_app()) response = client.post( "/telegram/webhook/expected-secret", json={"message": {"chat": {"id": 123}, "text": "hello"}}, ) assert response.status_code == 200 assert response.json() == { "method": "sendMessage", "chat_id": 123, "text": "Babble stub response: backend online and ready.", "reply_markup": { "keyboard": [ ["Menu", "Characters", "Build Character"], ["Warm", "Romantic", "Playful", "Deep"], ["Babble", "Charlotte", "Jaylaa"], ["Memory On", "Reset", "Help"], ], "resize_keyboard": True, "is_persistent": True, "input_field_placeholder": "Tap a button or send a message", }, } def test_persona_command_switches_active_persona(monkeypatch) -> None: reloaded_app = configure_webhook(monkeypatch) client = TestClient(reloaded_app.create_app()) response = client.post( "/telegram/webhook/expected-secret", json={"message": {"chat": {"id": 123}, "text": "/persona playful"}}, ) assert response.status_code == 200 assert FakeTelegramClient.requests[-1]["json"]["text"] == ( "Persona switched to playful (Charlotte)." ) client.post( "/telegram/webhook/expected-secret", json={"message": {"chat": {"id": 123}, "text": "/menu"}}, ) assert "playful" in FakeTelegramClient.requests[-1]["json"]["text"] def test_character_command_updates_custom_profile(monkeypatch) -> None: reloaded_app = configure_webhook(monkeypatch) client = TestClient(reloaded_app.create_app()) response = client.post( "/telegram/webhook/expected-secret", json={ "message": { "chat": {"id": 123}, "text": ( '/character name=Nova age=21 gender=woman ' 'style="teasing and warm" voice=soft mood=curious ' 'boundaries="adult romance only" opening="Come closer."' ), } }, ) assert response.status_code == 200 text = FakeTelegramClient.requests[-1]["json"]["text"] assert "Character updated." in text assert "Nova" in text assert "Age: 21" in text def test_missing_message_does_not_crash(monkeypatch) -> None: reloaded_app = configure_webhook(monkeypatch) client = TestClient(reloaded_app.create_app()) response = client.post("/telegram/webhook/expected-secret", json={"ok": True}) assert response.status_code == 200 assert response.json() == {"ok": True, "ignored": "missing_chat_id"} assert FakeTelegramClient.requests == [] def test_memory_store_persists_turns(tmp_path: Path, monkeypatch) -> None: memory_path = tmp_path / "chat_memory.json" reloaded_app = load_app( monkeypatch, MEMORY_ENABLED="true", MEMORY_PATH=str(memory_path), LLM_PROVIDER="stub", ) record = reloaded_app._default_session_record() record = reloaded_app._record_turn(record, "user", "hello") record = reloaded_app._record_turn(record, "assistant", "hi there") reloaded_app._set_session_record("123", record) assert memory_path.exists() payload = json.loads(memory_path.read_text(encoding="utf-8")) assert payload["123"]["memory"]["turns"][-2:] == [ {"role": "user", "text": "hello"}, {"role": "assistant", "text": "hi there"}, ] def test_debug_config_reports_persona_and_memory(monkeypatch) -> None: reloaded_app = load_app( monkeypatch, DEBUG="true", LLM_PROVIDER="stub", PERSONA_ID="playful", ) client = TestClient(reloaded_app.create_app()) response = client.get("/debug/config") assert response.status_code == 200 body = response.json() assert body["persona_id"] == "playful" assert body["persona_name"] == "Charlotte" assert body["memory_enabled"] is False assert body["persona_count"] >= 3 def test_debug_model_surfaces_local_model_failure(monkeypatch) -> None: reloaded_app = load_app( monkeypatch, DEBUG="true", LOCAL_MODEL_ENABLED="false", ) client = TestClient(reloaded_app.create_app()) response = client.get("/debug/model") assert response.status_code == 200 body = response.json() assert body["ok"] is False assert body["error"] == "RuntimeError" assert body["detail"] == "Local model is disabled" def test_model_endpoint_respects_provider_knob(monkeypatch) -> None: reloaded_app = load_app(monkeypatch, LLM_PROVIDER="stub") client = TestClient(reloaded_app.create_app()) response = client.get("/model") assert response.status_code == 200 body = response.json() assert body["model_id"] == "Qwen/Qwen3-0.6B" assert body["enabled"] is False assert body["loaded"] is False assert body["persona_id"] == "default" assert body["memory_enabled"] is False