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>
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 | ||