Files
sales-trainer/backend/scripts/test_user_journey.py
Macky 5b167b4546 feat(training): delete group button (admin only) on Training page
- Backend DELETE /api/groups/<gid> (admin, own-org enforced via _get_owned_group);
  cascades removal of the group's sessions. GroupStore.delete added.
- Training.vue: each group card gets a trash (Trash2 line icon) delete button shown only
  to admins (auth.isAdmin); confirm dialog; @click.stop so it doesn't navigate. Card
  restructured so the delete button is not inside the clickable router-link.
- test_user_journey: trainee cannot delete (403), admin deletes, group gone (404).
All suites pass. Rebuilt dist.
2026-08-09 10:20:10 +07:00

107 lines
5.3 KiB
Python

"""Full user-journey flow test (mock LLM) covering the exact 'idea' flow end-to-end:
admin create group -> analyze -> personas -> trainee picks scenario -> chat ->
persona decides -> debrief readable. Verifies no 500s and debrief is user-readable.
"""
import io, os, sys, tempfile, warnings
from pathlib import Path
warnings.filterwarnings("ignore")
BACKEND = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, BACKEND)
from app.factory import create_app
from app.config import Config
td = tempfile.mkdtemp(); Config.DATA_DIR = Path(td)
sys.path.insert(0, BACKEND + "/scripts")
from mock_llm import MockLLM
app = create_app(); app.extensions["llm"] = MockLLM()
C = app.test_client()
def tok(u, p): return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"]
AT = tok("admin", "1234"); AH = {"Authorization": f"Bearer {AT}"}
C.post("/api/auth/setup", headers=AH, json={"username":"admin","email":"a@b.co","password":"newpass","accepted_terms":True})
AT = tok("admin", "newpass"); AH = {"Authorization": f"Bearer {AT}"}
# 1. admin creates group
r = C.post("/api/groups", headers=AH, json={"product":"Point-of-Sale CRM","segment":"SME restaurants","channel":"line","language":"th"})
assert r.status_code == 201, r.get_json()
gid = r.get_json()["group"]["id"]
print("[ok] admin created group", gid[:8])
# 2. analyze
r = C.post(f"/api/groups/{gid}/analyze", headers=AH)
assert r.status_code == 200, r.get_json()
g = C.get(f"/api/groups/{gid}", headers=AH).get_json()["group"]
assert g.get("status") in ("ready", "draft"), g.get("status")
print("[ok] analyze produced", g.get("status"))
# 3. super_admin sees personas WITH secret fields (recipe), tiers populated
ps = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
assert ps and len(ps) >= 3
assert "pains" in ps[0], "super_admin should see recipe fields"
print("[ok] super_admin sees", len(ps), "personas incl. recipe (pains)")
# 4. create a trainee (user) in the same org, login
C.post("/api/admin/users", headers=AH, json={"username":"trainee","password":"pppp","role":"user"})
TT = tok("trainee", "pppp"); TH = {"Authorization": f"Bearer {TT}"}
# 5. trainee picks scenario and starts chat with persona 0
pid = ps[0]["id"]
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario":"social","locale":"th"})
assert r.status_code == 200, r.get_json()
assert any(m.get("role") == "customer" for m in r.get_json()["session"]["messages"]), "social = customer opens"
print("[ok] trainee started social scenario; customer opens")
# 6. trainee sends messages until persona decides (mock decides buy on first send)
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/send", headers=TH, json={"text":"สวัสดีครับ ช่วยแนะนำหน่อยได้ไหม"})
assert r.status_code == 200, r.get_json()
b = r.get_json()
assert b.get("finished") is True and b.get("outcome") == "won", b
db = b.get("debrief") or {}
# 7. debrief must be user-readable: no raw nested JSON blobs leaking internals unexpectedly
assert db.get("outcome") == "won"
assert isinstance(db.get("coaching"), list) and db.get("coaching")
assert isinstance(db.get("why"), str) and db.get("why")
print("[ok] chat ended (persona decided buy); debrief readable")
# 8. one-shot: trainee cannot start again on same persona
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario":"social"})
assert r.status_code in (400, 409), r.get_json()
print("[ok] one-shot enforced: cannot restart finished persona")
# 9. admin (non-super) sees persona WITHOUT recipe (IP protection)
C.post("/api/admin/users", headers=AH, json={"username":"adm","password":"pppp","role":"admin"})
ADM = tok("adm","pppp"); ADH = {"Authorization": f"Bearer {ADM}"}
ps2 = C.get(f"/api/groups/{gid}/personas", headers=ADH).get_json()["personas"]
assert "pains" not in ps2[0] and "tolerance" not in ps2[0], "admin must NOT see recipe"
print("[ok] admin sees personas but recipe fields stripped (IP)")
# 10. trainee sees a minimal revealable persona (no hidden signals)
pt = C.get(f"/api/groups/{gid}/personas", headers=TH).get_json()["personas"]
pfirst = pt[0]
for hidden in ("pains","tolerance","negotiation_levers","opener","income","background"):
assert hidden not in pfirst, f"trainee should not see '{hidden}' before chat"
print("[ok] trainee sees only revealable persona (latent hidden)")
# 11. 'create more personas' = append (does not replace existing ones)
before = len(C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"])
r = C.post(f"/api/groups/{gid}/analyze?append=true", headers=AH)
assert r.status_code == 200, r.get_json()
after = len(r.get_json()["personas"])
assert after > before, f"append should add personas (before={before}, after={after})"
print(f"[ok] append adds personas ({before} -> {after}); existing kept")
# 12. delete group: trainee cannot, admin can
r = C.delete(f"/api/groups/{gid}", headers=TH)
assert r.status_code == 403, ("trainee must not delete group", r.status_code)
print("[ok] trainee cannot delete group (403)")
r = C.delete(f"/api/groups/{gid}", headers=AH)
assert r.status_code == 200, r.get_json()
gone = C.get(f"/api/groups/{gid}", headers=AH)
assert gone.status_code == 404, "deleted group should be gone"
print("[ok] admin deletes group; group no longer reachable")
print("ALL USER-JOURNEY FLOW TESTS PASSED")