SPB Git

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

recherche: /api/v1/search + /api/v1/suggest — proxy du moteur Trouve-Ka (pivot moteur de recherche Groupe KA)

Simon-Pierre Boucher committed (Aug 23, 2026) parent 8ac60ec Browse files

10 changed files +957 −1

modified .env.example +2 −0
@@ -18,6 +18,8 @@ API_PORT=8000
18 DAILY_RUN_HOUR=02 18 DAILY_RUN_HOUR=02
19 BACKUP_RETENTION_DAYS=90 19 BACKUP_RETENTION_DAYS=90
20 RATE_LIMIT_PER_MINUTE=120 20 RATE_LIMIT_PER_MINUTE=120
21+TROUVEKA_SEARCH_URL=https://www.trouve-ka.com/api/search
22+TROUVEKA_SUGGEST_URL=https://www.trouve-ka.com/api/suggest
21 LOUKA_SOURCE_URL= 23 LOUKA_SOURCE_URL=
22 IMMOKA_SOURCE_URL= 24 IMMOKA_SOURCE_URL=
23 FOODKA_SOURCE_URL= 25 FOODKA_SOURCE_URL=
added frontend/src/ka/GroupeKaBadge.tsx +21 −0
@@ -0,0 +1,21 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable
3+// vers le hub. À placer dans le header de chaque site (à côté du logo de la
4+// marque). Variante dark pour les surfaces encre. Zone de protection : le
5+// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge.
6+export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) {
7+ return (
8+ <a
9+ className={`gk-badge${dark ? " gk-badge--dark" : ""}`}
10+ href="https://www.groupe-ka.com"
11+ target="_blank"
12+ rel="noopener noreferrer"
13+ aria-label="Un service Groupe KA — visiter le portail de l'écosystème"
14+ >
15+ Un service
16+ <b>
17+ Groupe<span className="ka">KA</span>
18+ </b>
19+ </a>
20+ );
21+}
added frontend/src/ka/KaFooter.tsx +91 −0
@@ -0,0 +1,91 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), à vendorer
3+// dans chaque app Vite/React sous frontend/src/ka/. Consomme ecosystem.json :
4+// toute modification des infos de contact se fait dans ka-ui/ecosystem.json
5+// puis se resynchronise sur les 12 sites (voir ka-ui/README.md).
6+import eco from "./ecosystem.json";
7+
8+type LocalLink = { label: string; href: string };
9+
10+export default function KaFooter({
11+ siteId,
12+ localLegal = [],
13+}: {
14+ /** id du site courant dans ecosystem.json (ex. "resto-ka") */
15+ siteId: string;
16+ /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */
17+ localLegal?: LocalLink[];
18+}) {
19+ const year = new Date().getFullYear();
20+ const site = eco.sites.find((s) => s.id === siteId);
21+ const others = eco.sites.filter((s) => s.id !== siteId);
22+ return (
23+ <footer className="ka-footer" id="contact">
24+ <div className="container">
25+ <a
26+ className="wordmark"
27+ href={eco.hub.url}
28+ target={siteId === "groupe-ka" ? undefined : "_blank"}
29+ rel="noopener noreferrer"
30+ aria-label="Groupe KA — le portail de l'écosystème"
31+ >
32+ Groupe <span className="ka">KA</span>
33+ </a>
34+ <p className="desc">
35+ {eco.org.tagline} Des centaines de connecteurs lisent les sites à la
36+ source, normalisent la donnée et se resynchronisent seuls.{" "}
37+ {site ? (
38+ <>
39+ <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
40+ {site.tagline} — est un service Groupe KA.
41+ </>
42+ ) : null}
43+ </p>
44+ <p className="notice">
45+ <b>{eco.org.disclaimer}</b>
46+ </p>
47+ <ul className="sites">
48+ {others.map((s) => (
49+ <li key={s.id}>
50+ <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
51+ {s.wordmark}
52+ </a>
53+ </li>
54+ ))}
55+ {eco.extraFooterLinks.map((l) => (
56+ <li key={l.href}>
57+ <a href={l.href} target="_blank" rel="noopener noreferrer">
58+ {l.label}
59+ </a>
60+ </li>
61+ ))}
62+ </ul>
63+ <div className="contacts">
64+ {eco.contacts.map((c) => (
65+ <p key={c.email} style={{ margin: 0 }}>
66+ <a href={`mailto:${c.email}`}>{c.email}</a>
67+ <span>{c.role}</span>
68+ </p>
69+ ))}
70+ </div>
71+ <p className="legal">
72+ © {year} {eco.org.copyrightHolder}
73+ {localLegal.map((l) => (
74+ <span key={l.href}>
75+ {" · "}
76+ <a href={l.href}>{l.label}</a>
77+ </span>
78+ ))}
79+ {eco.legal.map((l) => (
80+ <span key={l.href}>
81+ {" · "}
82+ <a href={l.href} target="_blank" rel="noopener noreferrer">
83+ {l.label}
84+ </a>
85+ </span>
86+ ))}
87+ </p>
88+ </div>
89+ </footer>
90+ );
91+}
added frontend/src/ka/ecosystem.json +196 −0
@@ -0,0 +1,196 @@
1+{
2+ "org": {
3+ "name": "Groupe KA",
4+ "legalName": "Groupe KA — Simon-Pierre Boucher",
5+ "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6+ "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7+ "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8+ },
9+ "hub": {
10+ "url": "https://www.groupe-ka.com",
11+ "loginPath": "/connexion",
12+ "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13+ },
14+ "contacts": [
15+ {
16+ "email": "contact@groupe-ka.com",
17+ "role": "Projets, partenariats & données"
18+ },
19+ {
20+ "email": "info@groupe-ka.com",
21+ "role": "Médias & questions générales"
22+ },
23+ {
24+ "email": "admin@groupe-ka.com",
25+ "role": "Légal, vie privée & Loi 25"
26+ }
27+ ],
28+ "legal": [
29+ {
30+ "label": "Conditions d'utilisation",
31+ "href": "https://www.groupe-ka.com/conditions"
32+ },
33+ {
34+ "label": "Politique de confidentialité",
35+ "href": "https://www.groupe-ka.com/confidentialite"
36+ },
37+ {
38+ "label": "Protection des renseignements personnels (Loi 25)",
39+ "href": "https://www.groupe-ka.com/loi-25"
40+ },
41+ {
42+ "label": "Transparence des robots d'indexation",
43+ "href": "https://www.groupe-ka.com/bots"
44+ }
45+ ],
46+ "sites": [
47+ {
48+ "id": "groupe-ka",
49+ "wordmark": "Groupe KA",
50+ "domain": "www.groupe-ka.com",
51+ "accent": "#d9f26b",
52+ "accentSoft": "#f0f9d2",
53+ "accentDeep": "#123f2e",
54+ "onAccent": "#141814",
55+ "tagline": "Le portail de l'écosystème ·Ka"
56+ },
57+ {
58+ "id": "trouve-ka",
59+ "wordmark": "Trouve·Ka",
60+ "domain": "www.trouve-ka.com",
61+ "accent": "#1c7ed6",
62+ "accentSoft": "#e7f2fd",
63+ "accentDeep": "#14508f",
64+ "onAccent": "#ffffff",
65+ "tagline": "Le moteur de recherche du web québécois"
66+ },
67+ {
68+ "id": "lou-ka",
69+ "wordmark": "Lou·Ka",
70+ "domain": "www.lou-ka.com",
71+ "accent": "#ff6a00",
72+ "accentSoft": "#fff1e6",
73+ "accentDeep": "#cc5500",
74+ "onAccent": "#ffffff",
75+ "tagline": "Tous les logements à louer"
76+ },
77+ {
78+ "id": "immo-ka",
79+ "wordmark": "Immo·Ka",
80+ "domain": "www.immo-ka.com",
81+ "accent": "#e23744",
82+ "accentSoft": "#fbe0e2",
83+ "accentDeep": "#a8232e",
84+ "onAccent": "#ffffff",
85+ "tagline": "Toutes les propriétés à vendre"
86+ },
87+ {
88+ "id": "vrai-prix",
89+ "wordmark": "Vrai-Prix",
90+ "domain": "www.vrai-prix.com",
91+ "accent": "#ff5148",
92+ "accentSoft": "#ffe3e0",
93+ "accentDeep": "#9e2a25",
94+ "onAccent": "#ffffff",
95+ "tagline": "La valeur réelle de chaque propriété"
96+ },
97+ {
98+ "id": "auto-ka",
99+ "wordmark": "Auto·Ka",
100+ "domain": "www.auto-ka.com",
101+ "accent": "#ff5a2a",
102+ "accentSoft": "#ffe8de",
103+ "accentDeep": "#cc3f16",
104+ "onAccent": "#ffffff",
105+ "tagline": "Les voitures usagées du Québec"
106+ },
107+ {
108+ "id": "fabri-ka",
109+ "wordmark": "Fabri·Ka",
110+ "domain": "www.fabri-ka.com",
111+ "accent": "#c4532e",
112+ "accentSoft": "#f7e3da",
113+ "accentDeep": "#a94525",
114+ "onAccent": "#ffffff",
115+ "tagline": "Les produits fabriqués au Québec"
116+ },
117+ {
118+ "id": "food-ka",
119+ "wordmark": "Food·Ka",
120+ "domain": "www.food-ka.com",
121+ "accent": "#1f9d55",
122+ "accentSoft": "#e2f5ea",
123+ "accentDeep": "#157a40",
124+ "onAccent": "#ffffff",
125+ "tagline": "Les prix d'épicerie, suivis à la source"
126+ },
127+ {
128+ "id": "resto-ka",
129+ "wordmark": "Resto·Ka",
130+ "domain": "www.resto-ka.com",
131+ "accent": "#f08c00",
132+ "accentSoft": "#fdeed7",
133+ "accentDeep": "#b96a00",
134+ "onAccent": "#141814",
135+ "tagline": "Chaque resto, chaque plat, chaque prix"
136+ },
137+ {
138+ "id": "sorti-ka",
139+ "wordmark": "Sorti·Ka",
140+ "domain": "www.sorti-ka.com",
141+ "accent": "#d6336c",
142+ "accentSoft": "#fbe0eb",
143+ "accentDeep": "#a12551",
144+ "onAccent": "#ffffff",
145+ "tagline": "Toutes les sorties, dans les 17 régions"
146+ },
147+ {
148+ "id": "crea-ka",
149+ "wordmark": "Créa·Ka",
150+ "domain": "www.crea-ka.com",
151+ "accent": "#7048e8",
152+ "accentSoft": "#ece5fc",
153+ "accentDeep": "#5433b8",
154+ "onAccent": "#ffffff",
155+ "tagline": "Les créateurs d'ici, tous leurs liens"
156+ },
157+ {
158+ "id": "api-ka",
159+ "wordmark": "API·Ka",
160+ "domain": "www.api-ka.com",
161+ "accent": "#3b5bdb",
162+ "accentSoft": "#e4eafb",
163+ "accentDeep": "#2b44a8",
164+ "onAccent": "#ffffff",
165+ "tagline": "La donnée de l'écosystème, par API"
166+ },
167+ {
168+ "id": "job-ka",
169+ "wordmark": "Job·Ka",
170+ "domain": "www.job-ka.com",
171+ "accent": "#0c8599",
172+ "accentSoft": "#def0f4",
173+ "accentDeep": "#095c6b",
174+ "onAccent": "#ffffff",
175+ "tagline": "Tous les emplois des employeurs québécois"
176+ }
177+ ],
178+ "extraFooterLinks": [
179+ {
180+ "label": "ValoPlex",
181+ "href": "https://www.valoplex.com"
182+ },
183+ {
184+ "label": "Ka2",
185+ "href": "https://www.ka2.bot"
186+ },
187+ {
188+ "label": "Ka4",
189+ "href": "https://www.ka4.bot"
190+ },
191+ {
192+ "label": "Ka6",
193+ "href": "https://www.ka6.bot"
194+ }
195+ ]
196+}
\ No newline at end of file
added frontend/src/ka/tokens.css +319 −0
@@ -0,0 +1,319 @@
1+/* -----------------------------------------------------------------------------
2+ Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+ Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4+ Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5+ de composants partagés par les 12 plateformes. Chaque site importe ce
6+ fichier PUIS surcharge uniquement son accent (voir accents.css).
7+
8+ · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9+ · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10+ · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11+ surlignés d'accent inclinés (néo-brutalisme raffiné)
12+ · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13+ · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14+----------------------------------------------------------------------------- */
15+
16+:root {
17+ /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18+ --paper: #f5f3ee;
19+ --surface: #ffffff;
20+ --surface-2: #faf9f5;
21+ --ink: #141814;
22+ --ink-2: #4d5551;
23+ --ink-3: #8b928c;
24+ --line: rgba(20, 24, 20, 0.14);
25+ --line-strong: rgba(20, 24, 20, 0.85);
26+ --green: #1c5c41;
27+ --green-deep: #123f2e;
28+ --amber: #e8a33d;
29+ --amber-soft: #fdf3e2;
30+ --danger: #b3423a;
31+ --danger-soft: #fbe9e7;
32+
33+ /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34+ --accent: #d9f26b; /* défaut : lime Groupe KA */
35+ --accent-soft: #f0f9d2;
36+ --accent-deep: #123f2e;
37+ --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38+
39+ /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40+ --lime: var(--accent);
41+ --lime-soft: var(--accent-soft);
42+
43+ /* ---- Géométrie sharp ---- */
44+ --r-card: 10px;
45+ --r-ctl: 6px;
46+ --r-pill: 999px;
47+
48+ /* ---- Ombres décalées — la signature du groupe ---- */
49+ --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50+ --shadow-off: 6px 6px 0 var(--ink);
51+ --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52+ --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53+
54+ /* ---- Typographie ---- */
55+ --font-display: "Space Grotesk", system-ui, sans-serif;
56+ --font-body: "Inter", system-ui, sans-serif;
57+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
58+
59+ /* Échelle fluide (mobile-first, clamp) */
60+ --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61+ --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62+ --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63+ --fs-body: 15px;
64+ --fs-small: 13px;
65+
66+ /* Espacements */
67+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68+ --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69+
70+ /* Cible tactile minimale */
71+ --touch: 44px;
72+
73+ /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----
74+ grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,
75+ purement visuel). Tout composant interactif se place sous 9999 : */
76+ --z-content: 1; /* contenu positionné ordinaire */
77+ --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */
78+ --z-header: 500; /* header du site */
79+ --z-bottombar: 600; /* tab bar / barre d'action basse */
80+ --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */
81+ --z-overlay: 800; /* voile sous modale/panneau */
82+ --z-modal: 900; /* modales, panneaux de filtres, galeries */
83+ --z-toast: 950; /* notifications */
84+
85+ /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se
86+ replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */
87+ --vh100: 100vh;
88+}
89+@supports (height: 100dvh) { :root { --vh100: 100dvh; } }
90+
91+/* ---- Socle ---- */
92+* { box-sizing: border-box; }
93+/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul
94+ ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;
95+ clip (≠ hidden) ne casse pas position:sticky. */
96+html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }
97+body {
98+ margin: 0;
99+ background: var(--paper);
100+ color: var(--ink);
101+ font-family: var(--font-body);
102+ font-size: var(--fs-body);
103+ line-height: 1.55;
104+ -webkit-font-smoothing: antialiased;
105+ padding-bottom: env(safe-area-inset-bottom);
106+ overflow-x: clip; /* jamais de débordement horizontal */
107+}
108+img, svg, video { max-width: 100%; height: auto; display: block; }
109+h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
110+h1 { font-size: var(--fs-h1); line-height: 1.06; }
111+h2 { font-size: var(--fs-h2); line-height: 1.15; }
112+h3 { font-size: var(--fs-h3); line-height: 1.25; }
113+a { color: inherit; }
114+::selection { background: var(--accent); color: var(--on-accent); }
115+:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
116+
117+/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
118+ relative seulement), sinon les utilitaires z-* sont écrasés. */
119+body::before {
120+ content: "";
121+ position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
122+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
123+}
124+body > * { position: relative; }
125+
126+/* ---- Conteneur commun ---- */
127+.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
128+@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
129+
130+/* ---- Micro-typographie signature ---- */
131+.kicker {
132+ display: inline-flex; align-items: center; gap: 10px;
133+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
134+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
135+}
136+.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
137+.klabel {
138+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
139+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
140+}
141+.hl {
142+ display: inline-block; background: var(--accent); color: var(--on-accent);
143+ border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
144+}
145+.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
146+
147+/* ---- Boutons (cible ≥ 44 px) ---- */
148+.btn {
149+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
150+ min-height: var(--touch); padding: 10px 18px;
151+ font-family: var(--font-display); font-weight: 700; font-size: 14px;
152+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
153+ background: var(--surface); color: var(--ink);
154+ cursor: pointer; text-decoration: none;
155+ transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
156+}
157+.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
158+.btn:active { transform: translate(0, 0); box-shadow: none; }
159+.btn:disabled { opacity: 0.45; pointer-events: none; }
160+.btn-primary { background: var(--ink); color: var(--accent); }
161+.btn-primary:hover { background: var(--accent-deep); }
162+.btn-accent { background: var(--accent); color: var(--on-accent); }
163+.btn-ghost { background: transparent; border-color: var(--line); }
164+.btn-ghost:hover { border-color: var(--ink); }
165+
166+/* ---- Cartes ---- */
167+.card {
168+ background: var(--surface); border: 1.5px solid var(--ink);
169+ border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
170+ overflow: hidden;
171+}
172+.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
173+.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
174+
175+/* ---- Chips / badges ---- */
176+.chip {
177+ display: inline-flex; align-items: center; gap: 6px;
178+ padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
179+ text-transform: uppercase; letter-spacing: 0.06em;
180+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
181+ background: var(--surface); color: var(--ink);
182+}
183+.chip-accent { background: var(--accent); color: var(--on-accent); }
184+.chip-soft { background: var(--accent-soft); border-color: var(--line); }
185+
186+/* ---- Champs ---- */
187+.input, .select, .textarea {
188+ width: 100%; min-height: var(--touch); padding: 10px 14px;
189+ font: inherit; color: var(--ink); background: var(--surface);
190+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
191+}
192+.input:focus, .select:focus, .textarea:focus {
193+ outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
194+}
195+.input::placeholder { color: var(--ink-3); }
196+
197+/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
198+.gk-badge {
199+ display: inline-flex; align-items: center; gap: 7px;
200+ min-height: 30px; padding: 3px 10px 4px;
201+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
202+ letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
203+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
204+ background: var(--surface); color: var(--ink-2); white-space: nowrap;
205+}
206+.gk-badge b {
207+ font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
208+ text-transform: none; color: var(--ink);
209+}
210+.gk-badge b .ka {
211+ display: inline-block; background: var(--ink); color: var(--accent);
212+ border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
213+}
214+.gk-badge:hover .ka { transform: rotate(0); }
215+.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
216+.gk-badge--dark b { color: var(--paper); }
217+
218+/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
219+.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
220+.ka-footer a { text-decoration: none; }
221+.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
222+.ka-footer .wordmark .ka { color: var(--accent); }
223+.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
224+.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
225+.ka-footer .notice b { color: var(--paper); }
226+.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
227+.ka-footer .sites a { color: rgba(245,243,238,0.65); }
228+.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
229+.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
230+@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
231+.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
232+.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
233+.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
234+.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
235+.ka-footer .legal a { color: inherit; }
236+.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
237+
238+/* ---- Tableaux → cartes empilées sous 768 px ---- */
239+.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
240+@media (max-width: 767px) {
241+ .tbl-stack thead { display: none; }
242+ .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
243+ .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
244+ .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
245+}
246+
247+/* ---- Utilitaires responsive ---- */
248+.only-mobile { display: initial; } .only-desktop { display: none; }
249+@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
250+
251+/* =============================================================================
252+ SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.
253+ RÈGLES pour tout composant futur :
254+ · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;
255+ le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).
256+ · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.
257+ · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/
258+ will-change/backdrop-filter — sinon monter l'élément à la racine (body).
259+ · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>.
260+ · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé).
261+ · z-index : uniquement l'échelle --z-* ci-dessus.
262+ · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).
263+ ========================================================================== */
264+
265+/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px
266+ déclenche un zoom automatique de page sur Safari iOS). !important assumé :
267+ c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi
268+ qu'il arrive — ne s'applique qu'aux écrans tactiles. */
269+@media (hover: none) and (pointer: coarse) {
270+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
271+ select, textarea { font-size: max(16px, 1em) !important; }
272+}
273+
274+/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer
275+ son rendu (débord de la zone cliquable via pseudo-élément). */
276+@media (pointer: coarse) {
277+ .gk-badge { position: relative; }
278+ .gk-badge::after { content: ""; position: absolute; inset: -8px; }
279+}
280+
281+/* Hauteurs viewport dynamiques */
282+.h-viewport { height: 100vh; height: 100dvh; }
283+.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
284+
285+/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les
286+ transforms accidentels, et compensation d'espace via body.has-bottombar. */
287+.ka-bottombar {
288+ position: fixed; left: 0; right: 0; bottom: 0;
289+ z-index: var(--z-bottombar);
290+ padding-bottom: env(safe-area-inset-bottom, 0px);
291+ background: var(--surface);
292+ border-top: 1.5px solid var(--ink);
293+ transform: none !important; filter: none !important;
294+}
295+body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }
296+
297+/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres
298+ ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */
299+html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }
300+
301+/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox
302+ incluses), défilement interne avec élan, jamais plus haut que l'écran. */
303+.ka-menu {
304+ position: absolute; z-index: var(--z-dropdown);
305+ min-width: 180px;
306+ max-height: min(60vh, 420px); max-height: min(60dvh, 420px);
307+ overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
308+ background: var(--surface); border: 1.5px solid var(--ink);
309+ border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
310+}
311+.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] {
312+ display: flex; align-items: center; min-height: var(--touch);
313+ padding: 10px 14px; width: 100%; text-decoration: none;
314+}
315+/* Voile plein écran sous une modale / un panneau */
316+.ka-overlay {
317+ position: fixed; inset: 0; z-index: var(--z-overlay);
318+ background: rgba(20, 24, 20, 0.45);
319+}
modified src/api/main.py +4 −1
@@ -34,6 +34,7 @@ from src.api.routes import (
34 iosauth, 34 iosauth,
35 monitoring, 35 monitoring,
36 runs, 36 runs,
37+ search,
37 services, 38 services,
38 stats, 39 stats,
39 ) 40 )
@@ -87,12 +88,14 @@ app.add_middleware(
87 app.add_middleware(RateLimitMiddleware) 88 app.add_middleware(RateLimitMiddleware)
88 app.add_middleware(RequestLoggingMiddleware) 89 app.add_middleware(RequestLoggingMiddleware)
89 90
90−# /api/v1/runs doit être enregistré avant les routes génériques /api/v1/{service}. 91+# /api/v1/runs et /api/v1/search doivent être enregistrés avant les routes
92+# génériques /api/v1/{service} (sinon leur catch-all capturerait ces chemins).
91 app.include_router(health.router) 93 app.include_router(health.router)
92 app.include_router(auth.router) 94 app.include_router(auth.router)
93 app.include_router(runs.router) 95 app.include_router(runs.router)
94 app.include_router(monitoring.router) 96 app.include_router(monitoring.router)
95 app.include_router(stats.router) 97 app.include_router(stats.router)
98+app.include_router(search.router)
96 app.include_router(services.router) 99 app.include_router(services.router)
97 app.include_router(agent.router) 100 app.include_router(agent.router)
98 app.include_router(iosauth.router) 101 app.include_router(iosauth.router)
added src/api/routes/search.py +121 −0
@@ -0,0 +1,121 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : src/api/routes/search.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-23
8+# ============================================
9+"""Routes /api/v1/search et /api/v1/suggest — recherche Trouve·Ka.
10+
11+Proxy public du moteur de recherche interne du Groupe KA (www.trouve-ka.com) :
12+API-KA relaie les requêtes vers le moteur amont et renvoie ses résultats dans
13+l'enveloppe uniforme ``{success, data, meta}`` de la plateforme.
14+"""
15+
16+from __future__ import annotations
17+
18+from typing import Any
19+
20+import httpx
21+from fastapi import APIRouter, HTTPException, Query
22+
23+from src.api.routes import envelope
24+from src.config import get_settings
25+
26+router = APIRouter(prefix="/api/v1", tags=["search"])
27+
28+# Le moteur Trouve·Ka plafonne `limit` à 50 — même plafond ici.
29+MAX_LIMIT = 50
30+
31+
32+async def _get(url: str, params: dict[str, Any]) -> Any:
33+ """GET JSON vers le moteur Trouve·Ka (mêmes réglages que le proxy agent)."""
34+ async with httpx.AsyncClient(timeout=12, follow_redirects=True) as cx:
35+ r = await cx.get(url, params=params)
36+ r.raise_for_status()
37+ return r.json()
38+
39+
40+@router.get("/search")
41+async def search(
42+ q: str = Query(
43+ ..., min_length=1, max_length=200, description="Termes de recherche"
44+ ),
45+ page: int = Query(1, ge=1, description="Numéro de page (défaut 1)"),
46+ limit: int = Query(
47+ 10, ge=1, le=MAX_LIMIT, description="Résultats par page (défaut 10, max 50)"
48+ ),
49+ language: str | None = Query(
50+ None, pattern="^(fr|en)$", description="Langue des résultats : fr ou en"
51+ ),
52+ site: str | None = Query(
53+ None,
54+ max_length=253,
55+ description="Limiter à un site — domaine, ex. www.lou-ka.com",
56+ ),
57+ category: str | None = Query(None, description="Catégorie Trouve·Ka"),
58+ freshness: str | None = Query(
59+ None,
60+ pattern="^(day|week|month|year)$",
61+ description="Fraîcheur : day, week, month ou year",
62+ ),
63+) -> dict[str, Any]:
64+ """Recherche web québécoise propulsée par le moteur Trouve·Ka.
65+
66+ Proxifie ``GET https://www.trouve-ka.com/api/search`` (moteur de recherche
67+ interne du Groupe KA) : résultats classés (sémantique + rerank), filtrables
68+ par langue, site, catégorie et fraîcheur. ``data`` contient les résultats
69+ (``title``, ``url``, ``snippet``, ``domain``, ``quebec_score``…) ; ``meta``
70+ expose ``total``, ``took_ms`` et les requêtes reliées (``related``).
71+ """
72+ params: dict[str, Any] = {"q": q, "page": page, "limit": limit}
73+ for key, value in (
74+ ("language", language),
75+ ("site", site),
76+ ("category", category),
77+ ("freshness", freshness),
78+ ):
79+ if value:
80+ params[key] = value
81+ try:
82+ data = await _get(get_settings().trouveka_search_url, params)
83+ except httpx.HTTPError as exc:
84+ raise HTTPException(
85+ status_code=502, detail=f"Moteur Trouve·Ka injoignable : {exc}"
86+ ) from exc
87+ return envelope(
88+ data.get("results", []),
89+ total=data.get("total"),
90+ page=data.get("page", page),
91+ limit=data.get("limit", limit),
92+ extra_meta={
93+ "query": data.get("query", q),
94+ "took_ms": data.get("took_ms"),
95+ "related": data.get("related", []),
96+ "source": "trouve-ka",
97+ },
98+ )
99+
100+
101+@router.get("/suggest")
102+async def suggest(
103+ q: str = Query(
104+ ...,
105+ min_length=2,
106+ max_length=200,
107+ description="Préfixe de recherche (2 caractères minimum)",
108+ ),
109+) -> dict[str, Any]:
110+ """Suggestions de recherche (autocomplétion) du moteur Trouve·Ka.
111+
112+ Proxifie ``GET https://www.trouve-ka.com/api/suggest`` : renvoie dans
113+ ``data`` la liste des suggestions de requêtes pour le préfixe donné.
114+ """
115+ try:
116+ data = await _get(get_settings().trouveka_suggest_url, {"q": q})
117+ except httpx.HTTPError as exc:
118+ raise HTTPException(
119+ status_code=502, detail=f"Moteur Trouve·Ka injoignable : {exc}"
120+ ) from exc
121+ return envelope(data.get("suggestions", []), extra_meta={"source": "trouve-ka"})
modified src/api/web/index.html +21 −0
@@ -346,6 +346,22 @@ const ENDPOINTS = [
346 const q=[["service",v.service_opt],["status",v.status],["page",v.page||1],["limit",v.limit||100]] 346 const q=[["service",v.service_opt],["status",v.status],["page",v.page||1],["limit",v.limit||100]]
347 .filter(([,x])=>x).map(([k,x])=>`${k}=${x}`).join("&"); 347 .filter(([,x])=>x).map(([k,x])=>`${k}=${x}`).join("&");
348 return `/api/v1/runs?${q}`;}}, 348 return `/api/v1/runs?${q}`;}},
349+ {id:"search", path:"/api/v1/search", titre:"Recherche Trouve·Ka",
350+ desc:"Recherche web québécoise propulsée par le moteur Trouve·Ka (www.trouve-ka.com) : résultats classés (sémantique + rerank), filtrables par site KA, langue et fraîcheur.",
351+ params:[
352+ ["q","query","termes de recherche",true],
353+ ["site","query","limiter à un site KA (ex. www.lou-ka.com)",false],
354+ ["page","query","page (défaut 1)",false],
355+ ["limit","query","résultats par page (max 50)",false]],
356+ fields:["q","site","page","limit50"],
357+ build:v=>{
358+ const p=[["q",encodeURIComponent(v.q||"montreal")],["site",v.site?encodeURIComponent(v.site):""],["page",v.page||1],["limit",v.limit50||10]]
359+ .filter(([,x])=>x).map(([k,x])=>`${k}=${x}`).join("&");
360+ return `/api/v1/search?${p}`;}},
361+ {id:"suggest", path:"/api/v1/suggest", titre:"Suggestions de recherche",
362+ desc:"Autocomplétion du moteur Trouve·Ka : suggestions de requêtes à partir d'un préfixe (2 caractères minimum).",
363+ params:[["q","query","préfixe de recherche (min. 2 caractères)",true]],
364+ fields:["q"], build:v=>`/api/v1/suggest?q=${encodeURIComponent(v.q||"logem")}`},
349 ]; 365 ];
350 366
351 function snippets(url){ 367 function snippets(url){
@@ -420,6 +436,9 @@ const FIELD_DEFS = {
420 date: {label:"Date (YYYY-MM-DD)", type:"date"}, 436 date: {label:"Date (YYYY-MM-DD)", type:"date"},
421 page: {label:"Page", type:"number", value:1, min:1}, 437 page: {label:"Page", type:"number", value:1, min:1},
422 limit: {label:"Limit (max 500)", type:"number", value:25, min:1, max:500}, 438 limit: {label:"Limit (max 500)", type:"number", value:25, min:1, max:500},
439+ limit50: {label:"Limit (max 50)", type:"number", value:10, min:1, max:50},
440+ q: {label:"Recherche (q)", type:"text", value:"montreal"},
441+ site: {label:"Site (optionnel, ex. www.lou-ka.com)", type:"text", value:""},
423 }; 442 };
424 443
425 function renderFields(){ 444 function renderFields(){
@@ -437,6 +456,8 @@ function renderFields(){
437 } else if (def.type === "date") { 456 } else if (def.type === "date") {
438 const today = new Date().toISOString().slice(0,10); 457 const today = new Date().toISOString().slice(0,10);
439 div.innerHTML = `<label for="${id}">${def.label}</label><input id="${id}" type="date" value="${today}">`; 458 div.innerHTML = `<label for="${id}">${def.label}</label><input id="${id}" type="date" value="${today}">`;
459+ } else if (def.type === "text") {
460+ div.innerHTML = `<label for="${id}">${def.label}</label><input id="${id}" type="text" value="${def.value||""}">`;
440 } else { 461 } else {
441 div.innerHTML = `<label for="${id}">${def.label}</label> 462 div.innerHTML = `<label for="${id}">${def.label}</label>
442 <input id="${id}" type="number" value="${def.value}" min="${def.min||0}" ${def.max?`max="${def.max}"`:""}>`; 463 <input id="${id}" type="number" value="${def.value}" min="${def.min||0}" ${def.max?`max="${def.max}"`:""}>`;
modified src/config.py +8 −0
@@ -80,6 +80,8 @@ class Settings:
80 daily_run_hour: int 80 daily_run_hour: int
81 backup_retention_days: int 81 backup_retention_days: int
82 rate_limit_per_minute: int 82 rate_limit_per_minute: int
83+ trouveka_search_url: str
84+ trouveka_suggest_url: str
83 source_urls: dict[str, str] 85 source_urls: dict[str, str]
84 base_dir: Path 86 base_dir: Path
85 logs_dir: Path 87 logs_dir: Path
@@ -105,6 +107,12 @@ def get_settings() -> Settings:
105 daily_run_hour=int(os.getenv("DAILY_RUN_HOUR", "02")), 107 daily_run_hour=int(os.getenv("DAILY_RUN_HOUR", "02")),
106 backup_retention_days=max(90, int(os.getenv("BACKUP_RETENTION_DAYS", "90"))), 108 backup_retention_days=max(90, int(os.getenv("BACKUP_RETENTION_DAYS", "90"))),
107 rate_limit_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")), 109 rate_limit_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "120")),
110+ trouveka_search_url=os.getenv(
111+ "TROUVEKA_SEARCH_URL", "https://www.trouve-ka.com/api/search"
112+ ),
113+ trouveka_suggest_url=os.getenv(
114+ "TROUVEKA_SUGGEST_URL", "https://www.trouve-ka.com/api/suggest"
115+ ),
108 source_urls={ 116 source_urls={
109 service: os.getenv(env_var, "") 117 service: os.getenv(env_var, "")
110 for service, env_var in SERVICE_SOURCE_ENV.items() 118 for service, env_var in SERVICE_SOURCE_ENV.items()
added tests/test_search.py +174 −0
@@ -0,0 +1,174 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : tests/test_search.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-23
8+# ============================================
9+"""Tests des routes /api/v1/search et /api/v1/suggest (proxy Trouve·Ka).
10+
11+Le moteur amont est mocké (monkeypatch de ``search._get``) : aucun appel
12+réseau réel pendant la suite de tests.
13+"""
14+
15+from __future__ import annotations
16+
17+from typing import Any
18+
19+import httpx
20+import pytest
21+from fastapi.testclient import TestClient
22+
23+from src.api.main import app
24+from src.api.routes import search as search_module
25+
26+FAKE_SEARCH = {
27+ "query": "montreal",
28+ "total": 42,
29+ "took_ms": 12,
30+ "page": 1,
31+ "limit": 3,
32+ "semantic": True,
33+ "reranked": True,
34+ "related": ["hôtel montreal"],
35+ "results": [
36+ {
37+ "title": "Montréal",
38+ "url": "https://example.com/montreal",
39+ "display_url": "example.com › montreal",
40+ "snippet": "La métropole du Québec.",
41+ "domain": "example.com",
42+ "language": "fr",
43+ "quebec_score": 0.9,
44+ "badges": [],
45+ "published_at": None,
46+ "image": None,
47+ }
48+ ],
49+}
50+
51+FAKE_SUGGEST = {"suggestions": ["logement montreal", "logement quebec"]}
52+
53+
54+@pytest.fixture(scope="module")
55+def client():
56+ """Client de test FastAPI avec cycle de vie (lifespan) actif."""
57+ with TestClient(app) as test_client:
58+ yield test_client
59+
60+
61+def _mock_upstream(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None:
62+ """Remplace le GET amont par un faux moteur qui capture url + params."""
63+
64+ async def fake_get(url: str, params: dict[str, Any]) -> Any:
65+ captured["url"] = url
66+ captured["params"] = params
67+ if "suggest" in url:
68+ return FAKE_SUGGEST
69+ return FAKE_SEARCH
70+
71+ monkeypatch.setattr(search_module, "_get", fake_get)
72+
73+
74+def test_search_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
75+ """GET /api/v1/search proxifie le moteur et renvoie l'enveloppe uniforme."""
76+ captured: dict[str, Any] = {}
77+ _mock_upstream(monkeypatch, captured)
78+ response = client.get("/api/v1/search", params={"q": "montreal", "limit": 3})
79+ assert response.status_code == 200
80+ body = response.json()
81+ assert body["success"] is True
82+ assert body["data"] == FAKE_SEARCH["results"]
83+ assert body["meta"]["total"] == 42
84+ assert body["meta"]["page"] == 1
85+ assert body["meta"]["limit"] == 3
86+ assert body["meta"]["query"] == "montreal"
87+ assert body["meta"]["took_ms"] == 12
88+ assert body["meta"]["related"] == ["hôtel montreal"]
89+ assert body["meta"]["source"] == "trouve-ka"
90+ assert captured["params"] == {"q": "montreal", "page": 1, "limit": 3}
91+
92+
93+def test_search_forwards_optional_filters(
94+ client: TestClient, monkeypatch: pytest.MonkeyPatch
95+) -> None:
96+ """Les filtres optionnels (language, site, category, freshness) sont transmis."""
97+ captured: dict[str, Any] = {}
98+ _mock_upstream(monkeypatch, captured)
99+ response = client.get(
100+ "/api/v1/search",
101+ params={
102+ "q": "logement",
103+ "language": "fr",
104+ "site": "www.lou-ka.com",
105+ "category": "immobilier",
106+ "freshness": "week",
107+ },
108+ )
109+ assert response.status_code == 200
110+ assert captured["params"]["language"] == "fr"
111+ assert captured["params"]["site"] == "www.lou-ka.com"
112+ assert captured["params"]["category"] == "immobilier"
113+ assert captured["params"]["freshness"] == "week"
114+
115+
116+def test_search_validation(client: TestClient) -> None:
117+ """q est requis, limit plafonné à 50, freshness/language contraints."""
118+ assert client.get("/api/v1/search").status_code == 422
119+ assert client.get("/api/v1/search", params={"q": ""}).status_code == 422
120+ assert (
121+ client.get("/api/v1/search", params={"q": "x", "limit": 51}).status_code == 422
122+ )
123+ assert (
124+ client.get("/api/v1/search", params={"q": "x", "language": "es"}).status_code
125+ == 422
126+ )
127+ assert (
128+ client.get("/api/v1/search", params={"q": "x", "freshness": "hour"}).status_code
129+ == 422
130+ )
131+
132+
133+def test_search_upstream_error_is_502(
134+ client: TestClient, monkeypatch: pytest.MonkeyPatch
135+) -> None:
136+ """Une erreur httpx du moteur amont devient un 502 explicite."""
137+
138+ async def broken(url: str, params: dict[str, Any]) -> Any:
139+ raise httpx.ConnectError("moteur injoignable")
140+
141+ monkeypatch.setattr(search_module, "_get", broken)
142+ response = client.get("/api/v1/search", params={"q": "montreal"})
143+ assert response.status_code == 502
144+ assert "Trouve·Ka" in response.json()["detail"]
145+
146+
147+def test_suggest_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
148+ """GET /api/v1/suggest renvoie les suggestions dans l'enveloppe uniforme."""
149+ captured: dict[str, Any] = {}
150+ _mock_upstream(monkeypatch, captured)
151+ response = client.get("/api/v1/suggest", params={"q": "logem"})
152+ assert response.status_code == 200
153+ body = response.json()
154+ assert body["success"] is True
155+ assert body["data"] == FAKE_SUGGEST["suggestions"]
156+ assert body["meta"]["source"] == "trouve-ka"
157+ assert captured["params"] == {"q": "logem"}
158+
159+
160+def test_suggest_min_length(client: TestClient) -> None:
161+ """q exige au moins 2 caractères."""
162+ assert client.get("/api/v1/suggest", params={"q": "l"}).status_code == 422
163+
164+
165+def test_search_not_captured_by_service_catchall(client: TestClient) -> None:
166+ """/api/v1/search ne doit PAS tomber dans le catch-all /api/v1/{service}.
167+
168+ Sans paramètres, la route search répond 422 (q requis) ; si le catch-all
169+ l'attrapait, on obtiendrait le 404 « Service inconnu : search ».
170+ """
171+ response = client.get("/api/v1/search")
172+ assert response.status_code == 422
173+ response = client.get("/api/v1/notaservice")
174+ assert response.status_code == 404
175
Menu

Theme