SPB Git

spb/ka-ui

Public

kaid/e2e-test.py : suite bout-en-bout (11 scénarios — favoris, affinités, négatif, séparation, cold start, rerank+badge, session intent, privacy, historique, recherches cross-univers, filtrage collaboratif) — à lancer sur M3U96b : ~/apps/lou-ka/.venv/bin/python kaid/e2e-test.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 26, 2026) parent cbe9c9f Browse files

1 changed file +227 −0

Unified Split
added kaid/e2e-test.py +227 −0
@@ -0,0 +1,227 @@
1+# Tests bout-en-bout KA ID v2 — exécuté sur M3U96b (accès hub local + secrets).
2+# Crée 2 membres de test, déroule les scénarios du cahier (§29), nettoie tout.
3+import hashlib
4+import hmac
5+import json
6+import os
7+import sqlite3
8+import sys
9+import time
10+import urllib.request
11+
12+HUB = "http://localhost:8110"
13+DB = os.path.expanduser("~/apps/groupe-ka/data/ka-id.db")
14+
15+# secrets clients depuis les .env des apps du nœud
16+def env_secret(path):
17+ for line in open(os.path.expanduser(path)):
18+ line = line.strip()
19+ if line.startswith("KA_SSO_SECRET="):
20+ return line.split("=", 1)[1].strip().strip('"').strip("'")
21+ raise SystemExit(f"KA_SSO_SECRET introuvable dans {path}")
22+
23+SECRETS = {
24+ "lou-ka": env_secret("~/apps/lou-ka/.env"),
25+ "crea-ka": env_secret("~/apps/crea-ka/.env"),
26+}
27+
28+con = sqlite3.connect(DB)
29+con.row_factory = sqlite3.Row
30+
31+# --- membres de test ---------------------------------------------------------
32+TA, TB = "ka-9900000001", "ka-9900000002"
33+for ka, email in [(TA, "test-kaid-a@test.local"), (TB, "test-kaid-b@test.local")]:
34+ con.execute("DELETE FROM users WHERE email=?", (email,))
35+ con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)",
36+ (email, "Membre Test", ka))
37+con.commit()
38+uid_a = con.execute("SELECT id FROM users WHERE ka_id=?", (TA,)).fetchone()["id"]
39+uid_b = con.execute("SELECT id FROM users WHERE ka_id=?", (TB,)).fetchone()["id"]
40+
41+def call(client, method, path, body=None, extra_qs=None, ka=TA):
42+ ts = str(int(time.time()))
43+ sig = hmac.new(SECRETS[client].encode(), f"{client}.{ka}.{ts}".encode(),
44+ hashlib.sha256).hexdigest()
45+ qs = f"client_id={client}&ka_id={ka}&ts={ts}&sig={sig}"
46+ if extra_qs:
47+ qs += "&" + extra_qs
48+ url = f"{HUB}{path}?{qs}"
49+ data = None
50+ if body is not None:
51+ body.update({"client_id": client, "ka_id": ka, "ts": ts, "sig": sig})
52+ data = json.dumps(body).encode()
53+ req = urllib.request.Request(url, data=data, method=method,
54+ headers={"Content-Type": "application/json"})
55+ try:
56+ with urllib.request.urlopen(req, timeout=10) as r:
57+ return r.status, json.loads(r.read())
58+ except urllib.error.HTTPError as e:
59+ return e.code, e.read().decode()[:200]
60+
61+results = []
62+def check(name, ok, detail=""):
63+ results.append((name, ok, detail))
64+ print(("PASS " if ok else "FAIL ") + name + (f" — {detail}" if detail and not ok else ""))
65+
66+# 1. favori « auto-ka » (simulé via lou-ka pour le secret dispo sur ce nœud)
67+# → visible dans les favoris cross-univers du hub + événement journalisé
68+st, _ = call("lou-ka", "POST", "/api/sso/favorites", {
69+ "action": "add",
70+ "item": {"item_id": "e2e:fav1", "title": "Logement test E2E",
71+ "meta": {"features": {"city": "Gatineau", "unit_type": "4 1/2",
72+ "price": 1500}}}})
73+favs = con.execute("SELECT * FROM favorites WHERE user_id=?", (uid_a,)).fetchall()
74+ev = con.execute("SELECT * FROM user_events WHERE user_id=? AND event_type='favorite'",
75+ (uid_a,)).fetchall()
76+check("favori → magasin central + événement journalisé",
77+ st == 200 and len(favs) == 1 and len(ev) == 1, f"st={st} favs={len(favs)} ev={len(ev)}")
78+
79+# 2. interactions → préférence mise à jour (affinité ville)
80+for i in range(3):
81+ call("lou-ka", "POST", "/api/sso/events", {"events": [
82+ {"type": "detail_view", "entity_id": f"e2e:{i}",
83+ "features": {"city": "Gatineau", "unit_type": "4 1/2", "price": 1450 + i}}]})
84+st, prefs = call("lou-ka", "GET", "/api/sso/prefs")
85+aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {})
86+check("interactions → affinité gatineau apprise",
87+ st == 200 and aff.get("gatineau", 0) >= 0.9, str(aff))
88+
89+# 3. signal négatif : hide → item exclu + affinité négative
90+st, _ = call("lou-ka", "POST", "/api/sso/hide",
91+ {"item_id": "e2e:bad", "on": True,
92+ "features": {"city": "Laval", "unit_type": "1 1/2"}})
93+st2, prefs = call("lou-ka", "GET", "/api/sso/prefs")
94+hidden = prefs.get("hidden") or []
95+aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {})
96+check("hide → exclu des résultats + affinité négative",
97+ st == 200 and "e2e:bad" in hidden and aff.get("laval", 0) < 0,
98+ f"hidden={hidden} laval={aff.get('laval')}")
99+
100+# 4. séparation stricte des utilisateurs : B ne voit rien de A
101+st, prefs_b = call("lou-ka", "GET", "/api/sso/prefs", ka=TB)
102+check("séparation utilisateurs (B vierge)",
103+ st == 200 and prefs_b.get("profile", {}).get("app") is None
104+ and not prefs_b.get("hidden"), json.dumps(prefs_b)[:120])
105+
106+# 5. cold start : profil vide → rerank neutre (module kaid de lou-ka)
107+sys.path.insert(0, os.path.expanduser("~/apps/lou-ka"))
108+os.environ.setdefault("KA_SSO_SECRET", SECRETS["lou-ka"])
109+from louka import kaid # noqa: E402
110+kaid.init("lou-ka")
111+items = [{"uid": f"u{i}", "city": "Québec", "price": 1000 + i} for i in range(10)]
112+out, personalized = kaid.rerank(
113+ list(items), {"ka_id": TB},
114+ features_of=lambda it: {"city": it["city"], "price": it["price"]})
115+check("cold start → ordre de base intact, non personnalisé",
116+ [o["uid"] for o in out] == [i["uid"] for i in items] and not personalized)
117+
118+# 6. reranking réel : le profil de A fait remonter Gatineau (tri par défaut)
119+kaid.invalidate_prefs(TA)
120+items = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2",
121+ "price": 1500} for i in range(8)]
122+ + [{"uid": "g1", "city": "Gatineau", "unit_type": "4 1/2", "price": 1480}])
123+out, personalized = kaid.rerank(
124+ list(items), {"ka_id": TA},
125+ features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"],
126+ "price": it["price"]})
127+pos = [o["uid"] for o in out].index("g1")
128+badge = next((o.get("ka_reco") for o in out if o["uid"] == "g1"), None)
129+others_badged = [o["uid"] for o in out if o.get("ka_reco") and o["uid"] != "g1"]
130+check("reranking → l'annonce Gatineau remonte (bornée par blend) + seul badge",
131+ personalized and pos < 8 and badge is not None and not others_badged,
132+ f"pos={pos} badge={badge} autres={others_badged}")
133+
134+# 7. intention de session : filtre explicite city=Québec → affinité ville ignorée
135+kaid.invalidate_prefs(TA)
136+out2, _ = kaid.rerank(
137+ list(items), {"ka_id": TA},
138+ features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"],
139+ "price": it["price"]},
140+ active_dims={"city"})
141+pos2 = [o["uid"] for o in out2].index("g1")
142+check("session intent → filtre explicite prime (Gatineau remonte moins)",
143+ pos2 >= pos, f"pos_sans_filtre={pos} pos_avec_filtre_city={pos2}")
144+
145+# 8. personnalisation OFF → aucun reranking (mais masqués toujours exclus)
146+con.execute("UPDATE users SET personalization=0 WHERE id=?", (uid_a,))
147+con.commit()
148+kaid.invalidate_prefs(TA)
149+st, prefs = call("lou-ka", "GET", "/api/sso/prefs")
150+items_h = list(items) + [{"uid": "e2e:bad", "city": "Laval",
151+ "unit_type": "1 1/2", "price": 900}]
152+out3, personalized3 = kaid.rerank(
153+ list(items_h), {"ka_id": TA},
154+ features_of=lambda it: {"city": it["city"], "price": it["price"]})
155+check("personnalisation OFF → pas de rerank, masqués exclus quand même",
156+ st == 200 and prefs.get("personalization") is False
157+ and not personalized3
158+ and [o["uid"] for o in out3] == [i["uid"] for i in items_h if i["uid"] != "e2e:bad"],
159+ f"perso={prefs.get('personalization')} p3={personalized3}")
160+
161+# 9. historique OFF → événements jetés
162+con.execute("UPDATE users SET personalization=1, history_enabled=0 WHERE id=?", (uid_a,))
163+con.commit()
164+n_before = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?",
165+ (uid_a,)).fetchone()["c"]
166+call("lou-ka", "POST", "/api/sso/events", {"events": [
167+ {"type": "detail_view", "entity_id": "e2e:drop", "features": {"city": "Lévis"}}]})
168+time.sleep(0.5)
169+n_after = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?",
170+ (uid_a,)).fetchone()["c"]
171+check("historique OFF → événement jeté par le hub", n_before == n_after,
172+ f"{n_before} → {n_after}")
173+
174+# 10. recherches sauvegardées cross-app : posée via crea-ka, listée scope=all
175+st, r = call("crea-ka", "POST", "/api/sso/saved-searches",
176+ {"action": "add", "search": {"label": "Créateurs food Québec",
177+ "filters": {"niche": "food"}, "alert": True}})
178+st2, lst = call("lou-ka", "GET", "/api/sso/saved-searches", extra_qs="scope=all")
179+apps_seen = {s["app"] for s in lst.get("searches", [])}
180+check("recherche sauvegardée cross-univers (scope=all)",
181+ st == 200 and st2 == 200 and "crea-ka" in apps_seen, str(apps_seen))
182+
183+# 11. filtrage collaboratif : C partage un favori avec A → son AUTRE favori
184+# apparaît dans similar de A et le rerank le booste (SIMILAR_USERS)
185+con.execute("UPDATE users SET history_enabled=1 WHERE id=?", (uid_a,))
186+con.execute("DELETE FROM users WHERE email='test-kaid-c@test.local'")
187+con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)",
188+ ("test-kaid-c@test.local", "Membre Test C", "ka-9900000003"))
189+con.commit()
190+uid_c = con.execute("SELECT id FROM users WHERE ka_id='ka-9900000003'").fetchone()["id"]
191+con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title) VALUES (?,?,?,?)",
192+ (uid_c, "lou-ka", "e2e:fav1", "Logement test E2E"))
193+con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title, url) VALUES (?,?,?,?,?)",
194+ (uid_c, "lou-ka", "e2e:cf-gem", "Perle co-favorite", "https://www.lou-ka.com/logement/e2e:cf-gem"))
195+con.execute("DELETE FROM user_prefs WHERE user_id=?", (uid_a,))
196+con.commit()
197+st, prefs = call("lou-ka", "GET", "/api/sso/prefs")
198+similar = (prefs.get("profile") or {}).get("app", {}).get("similar") or []
199+kaid.invalidate_prefs(TA)
200+items_cf = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2",
201+ "price": 1500} for i in range(6)]
202+ + [{"uid": "e2e:cf-gem", "city": "Québec", "unit_type": "5 1/2",
203+ "price": 1500}])
204+out_cf, _ = kaid.rerank(
205+ list(items_cf), {"ka_id": TA},
206+ features_of=lambda it: {"city": it["city"], "price": it["price"]})
207+pos_cf = [o["uid"] for o in out_cf].index("e2e:cf-gem")
208+reco_cf = next((o.get("ka_reco") for o in out_cf if o["uid"] == "e2e:cf-gem"), None)
209+check("filtrage collaboratif → co-favori détecté + boosté (SIMILAR_USERS)",
210+ "e2e:cf-gem" in similar and pos_cf < 6 and reco_cf is not None
211+ and "SIMILAR_USERS" in (reco_cf or {}).get("reasons", []),
212+ f"similar={similar} pos={pos_cf} reco={reco_cf}")
213+
214+# --- nettoyage ---------------------------------------------------------------
215+for uid in (uid_a, uid_b, uid_c):
216+ for table in ("user_events", "favorites", "saved_searches", "hidden_items",
217+ "user_prefs", "pref_overrides"):
218+ con.execute(f"DELETE FROM {table} WHERE user_id=?", (uid,))
219+con.execute("DELETE FROM users WHERE email LIKE 'test-kaid-%@test.local'")
220+con.execute("DELETE FROM favorites WHERE item_id LIKE 'e2e:%'")
221+con.commit()
222+left = con.execute("SELECT COUNT(*) c FROM users WHERE ka_id IN (?,?)", (TA, TB)).fetchone()["c"]
223+print(f"\nnettoyage : membres de test supprimés (restants={left})")
224+
225+fails = [n for n, ok, _ in results if not ok]
226+print(f"\n== {len(results) - len(fails)}/{len(results)} tests PASS ==")
227+sys.exit(1 if fails else 0)
228
Menu

Theme