SPB Git forge

spb/ka-ui

Public
30commits 1branches 0releases
145.7 MBsize
maindefault branch
27 days agolast push
Python 33.5% JavaScript 30.1% TypeScript 25% CSS 10% Shell 1.4%

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 29 days ago (Aug 26, 2026) parent cbe9c9f

1 changed file +227 −0

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