Files
sales-trainer/backend/scripts/test_e2e.py
Macky aa2eb8dd37 feat(chat): persona decides to buy/walk — auto-finish + tolerance (temper) + resume
The conversation now ENDS when the persona makes a decision (option C), not when the
trainee clicks a button:
- persona_id replies carry {reply, decision(none/buy/walk), mood}; when decision is
  buy/walk the session auto-finishes (won/lost) with a debrief that reveals latent
  details + per-turn 'turning points'.
- personas have a tolerance (1-5, 'temper'): impatient personas walk away fast after
  poor answers (fed via internal.misses on mood<=-1); tough 'wrong text' cases can
  still be won by a strong, gentle response (judge realism).
- trainee 'Finish' button removed; if they leave mid-chat an active session is resumed
  via /chat/resume (continue, not restart). One-shot lock still enforced once decided.
- mock/tests updated: persona deciding buy -> send auto-finishes won.
Rebuilt dist.
2026-08-08 14:34:34 +07:00

143 lines
5.9 KiB
Python

"""Full E2E test with a mock LLM: analyze → personas → chat → debrief → board/analytics."""
import os
import sys
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore", message="The HMAC key is")
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # for mock_llm
tempdir = tempfile.mkdtemp(prefix="st_e2e_")
os.environ["DATA_DIR"] = tempdir
os.environ["JWT_SECRET"] = "test-secret-key-0123456789abcdef"
from mock_llm import MockLLM # noqa: E402
from app.factory import create_app # noqa: E402
from app.config import Config # noqa: E402
Config.DATA_DIR = Path(tempdir)
Config.LLM_API_KEY = ""
Config.LLM_BASE_URL = ""
def main():
app = create_app()
app.extensions["llm"] = MockLLM()
client = app.test_client()
# admin login
r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"})
AT = r.get_json()["token"]
AH = {"Authorization": f"Bearer {AT}"}
# create group
r = client.post("/api/groups", json={
"product": "Cloud POS for small restaurants", "segment": "SME restaurants",
"channel": "line", "language": "th"}, headers=AH)
assert r.status_code == 201, r.get_json()
gid = r.get_json()["group"]["id"]
# analyze -> sales kit + 15 personas
r = client.post(f"/api/groups/{gid}/analyze", headers=AH)
assert r.status_code == 200, r.get_json()
body = r.get_json()
assert body["sales_kit"]["productName"] == "CloudPOS", body["sales_kit"]
personas = body["personas"]
assert len(personas) == 15, f"expected 15 personas, got {len(personas)}"
tiers = {}
for p in personas:
tiers.setdefault(p["tier"], 0)
tiers[p["tier"]] += 1
assert tiers == {"A": 5, "B": 5, "C": 5}, tiers
# wrong_text special in tier C
assert any(p["tier"] == "C" and p["special"] == "wrong_text" for p in personas), "no wrong_text persona"
print(f"[ok] analyze -> sales kit + 15 personas (tiers {tiers}), wrong_text present")
# create a trainee
client.post("/api/admin/users", json={
"name": "Trainee", "username": "trainee9", "password": "pass123", "role": "user"}, headers=AH)
r = client.post("/api/auth/login", json={"username": "trainee9", "password": "pass123"})
UT = r.get_json()["token"]
UH = {"Authorization": f"Bearer {UT}"}
# trainee sees group + personas but revealable-only (no pain/income)
r = client.get(f"/api/groups/{gid}/personas", headers=UH)
assert r.status_code == 200
plist = r.get_json()["personas"]
assert len(plist) == 15
first = plist[1]
assert "pains" not in first and "income" not in first, "latent fields leaked!"
assert "profession" in first and "initiation_mode" in first
print("[ok] trainee sees revealable-only persona fields (latent hidden)")
# pick a customer-initiated persona -> start session (customer opens)
cust = next(p for p in personas if p["initiation_mode"] == "customer")
pid = cust["id"]
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
assert r.status_code == 200, r.get_json()
session = r.get_json()["session"]
assert session["status"] == "active"
assert len(session["messages"]) >= 1
assert any(m["role"] == "customer" for m in session["messages"]), "customer should open"
print("[ok] customer-initiated session starts with customer opener")
# send messages
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
assert r.status_code == 200, r.get_json()
body = r.get_json()
assert body["reply"]
# Mock persona decides to buy on this send -> session auto-finishes as won.
assert body.get("finished") is True, body
assert body.get("outcome") == "won", body
debrief = body.get("debrief") or {}
assert debrief.get("outcome") == "won"
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
print("[ok] send -> persona decides (buy) -> session auto-finishes won with debrief")
# ONE-SHOT: cannot start again on same persona
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
assert r.status_code == 400, r.get_json()
print("[ok] one-shot enforced (cannot re-chat same persona)")
# seller-facing persona + f2f_call scenario -> session starts WITHOUT opener (seller must open)
sel = next(p for p in personas if p["initiation_mode"] == "seller")
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start",
json={"scenario": "f2f_call"}, headers=UH)
assert r.status_code == 200, r.get_json()
s2 = r.get_json()["session"]
assert "initiation_mode" in r.get_json()
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f_call: no customer opener (seller must open)"
print("[ok] seller-first (f2f/call) session — no customer opener, seller must open")
# board
r = client.get("/api/me/board", headers=UH)
board = r.get_json()["board"]
assert any(b["persona_id"] == pid and b["my_outcome"] == "won" for b in board)
print("[ok] win/lose board reflects won persona")
# weak-areas (no losses yet -> empty insight but endpoint works)
r = client.get("/api/me/weak-areas", headers=UH)
assert r.status_code == 200
print("[ok] weak-areas endpoint")
# generate own persona (manual, mock)
r = client.post("/api/me/personas/generate", json={"mode": "manual", "spec": {"target": "price-hardball"}}, headers=UH)
assert r.status_code == 201, r.get_json()
print("[ok] user generates own persona (manual)")
# analytics (admin)
r = client.get("/api/analytics", headers=AH)
assert r.status_code == 200
a = r.get_json()
assert a["overall"]["wins"] >= 1
print("[ok] admin analytics aggregates wins")
print("\nALL E2E TESTS PASSED")
if __name__ == "__main__":
main()