Files
gogo-telefon/tests/test_chat_widget.py
Senad Uka 646917c322 M3: chat widget, owner dashboard, super-admin panel
- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
  WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
  and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
  admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
  call/chat history with transcripts, usage bar; settings for profile,
  working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
  cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
  read-only + calendar mappings + buffers, partner status), telephony
  (forwarding MMI codes per operator, ring group, worker SIP creds + QR),
  agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
  partner API config (encrypted secrets), number/SIM registry + assignment,
  connectivity test (live availability + dry-run push), versioned prompt
  template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
  path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
  admin verified live in a browser against Postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:51:57 +02:00

155 lines
5.9 KiB
Python

"""Chat widget e2e (M3): WebSocket booking flow → request in DB + proposal email.
Uses Starlette's sync TestClient for WebSocket support; the scripted LLM drives
the same agent loop as production.
"""
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm
from gogo.main import create_app
def make_client() -> TestClient:
return TestClient(create_app())
@pytest.fixture(autouse=True)
def fast_chat(monkeypatch):
"""Scripted LLM answers instantly — disable the per-second message gate."""
from gogo.chat import router as chat_router
monkeypatch.setattr(chat_router, "MIN_SECONDS_BETWEEN_MESSAGES", 0.0)
async def test_widget_js_served(gogo_client, session):
resp = await gogo_client.get("/widget.js")
assert resp.status_code == 200
assert "gogo-bubble" in resp.text
assert resp.headers["content-type"].startswith("application/javascript")
def test_ws_rejects_unknown_key(session, tenant):
client = make_client()
# server closes before accepting → the connect itself raises with our code
with pytest.raises(WebSocketDisconnect) as exc: # noqa: SIM117
with client.websocket_connect("/widget/ws?key=nepostojeci"):
pass
assert exc.value.code == 4404
def test_ws_booking_flow_creates_request_and_email(session, tenant, emails, sms):
"""Full widget booking: greeting → messages → submit_booking_request →
request stored, proposal email sent, chat transcript persisted."""
import anyio
from sqlalchemy import select
from gogo.models import BookingRequest, ChatMessage
set_llm(
ScriptedLLM(
[
LLMResponse(text="Može! Koji je vaš broj telefona i ime?"),
LLMResponse(
text="",
tool_uses=[
ToolUse(
id="t1",
name="submit_booking_request",
input={
"service_name_raw": "manikir",
"client_name": "Lejla",
"client_phone": "+38761555444",
"time_preference_text": "petak poslijepodne",
"summary": "Manikir, petak poslijepodne.",
},
)
],
stop_reason="tool_use",
),
LLMResponse(
text="Zahtjev sam proslijedila salonu — kontaktiraće vas radi potvrde!"
),
]
)
)
try:
client = make_client()
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
greeting = ws.receive_json()
assert greeting["type"] == "greeting"
assert "Gogo" in greeting["text"]
resume_token = greeting["session"]
assert resume_token
ws.send_json({"type": "message", "text": "Može manikir u petak?", "website": ""})
assert ws.receive_json()["type"] == "typing"
reply = ws.receive_json()
assert reply["type"] == "message"
assert "broj telefona" in reply["text"]
ws.send_json({"type": "message", "text": "Lejla, 061 555 444"})
assert ws.receive_json()["type"] == "typing"
reply2 = ws.receive_json()
assert "proslijedila" in reply2["text"]
# verify persisted state via a fresh event loop session
async def check():
import gogo.db as db
async with db.get_sessionmaker()() as s:
req = (await s.execute(select(BookingRequest))).scalar_one()
assert req.client_name == "Lejla"
assert req.source == "chat"
assert req.chat_session_id is not None
msgs = (await s.execute(select(ChatMessage))).scalars().all()
roles = [m.role for m in msgs]
assert roles.count("user") == 2
assert roles.count("assistant") >= 3 # greeting + 2 replies
anyio.run(check)
assert len(emails) == 1 # proposal email to the owner
assert "Lejla" in emails[0].subject
finally:
set_llm(None)
def test_ws_honeypot_drops_message(session, tenant, emails):
set_llm(ScriptedLLM([LLMResponse(text="NE BI SMIO STIĆI")]))
try:
client = make_client()
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
ws.receive_json() # greeting
# bot fills the hidden field → silently ignored
ws.send_json({"type": "message", "text": "spam", "website": "http://spam.example"})
# then a real empty check: send another honeypot message; no replies expected
ws.send_json({"type": "message", "text": "spam2", "website": "x"})
# close cleanly — if the agent had run, ScriptedLLM would have been consumed
assert True
finally:
set_llm(None)
def test_ws_resume_restores_history(session, tenant, emails):
set_llm(ScriptedLLM([LLMResponse(text="Prvi odgovor.")]))
try:
client = make_client()
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
token = ws.receive_json()["session"]
ws.send_json({"type": "message", "text": "Zdravo!"})
ws.receive_json() # typing
ws.receive_json() # reply
with client.websocket_connect(
f"/widget/ws?key={tenant.widget_public_key}&resume={token}"
) as ws:
data = ws.receive_json()
assert data["type"] == "resume"
texts = [m["text"] for m in data["messages"]]
assert "Zdravo!" in texts
assert "Prvi odgovor." in texts
finally:
set_llm(None)