diff --git a/backend/main.py b/backend/main.py index bb8f117..44e0c16 100644 --- a/backend/main.py +++ b/backend/main.py @@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # #223 : l'origine du service d'agents n'est pas figée — elle # est dérivée de la configuration (DRACO_AGENT_URL, sinon # l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel). - from backend.draco_config import agent_origine + from backend.draco_config import agent_origine, origine, urls_publiques agent = agent_origine() + # Fusion #69 : le panneau Alertes unifiées lit les API + # notifications des applications soeurs depuis cet hôte ; + # leurs origines rejoignent connect-src (même résolution + # draco_config que les liens de la barre : explicite > + # dérivation depuis l'hôte de l'agent > défaut officiel). + _soeurs69 = " ".join(sorted({ + origine(v) + for k, v in urls_publiques().items() + if k in ("obsigate", "flowdeck", "tiktak", "kanbante") + } - {""})) response.headers["Content-Security-Policy"] = ( "default-src 'self'; " f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; " "img-src 'self' data: blob:; " - f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; " + f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; " "font-src 'self' data: https://fonts.gstatic.com https://esm.sh; " "worker-src 'self' blob:; " f"frame-src 'self' blob: {agent}; " diff --git a/frontend/draco-alertes.css b/frontend/draco-alertes.css new file mode 100644 index 0000000..0d0582e --- /dev/null +++ b/frontend/draco-alertes.css @@ -0,0 +1,39 @@ +/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables + * d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */ +.draco-alertes-overlay { + position: fixed; inset: 0; z-index: 30000; + display: none; align-items: flex-start; justify-content: center; + padding: 8vh 16px 16px; + background: rgb(0 0 0 / .55); overflow-y: auto; +} +.draco-alertes-box { + width: 720px; max-width: 100%; + background: var(--bg-primary, #1f1f1f); + color: var(--text, #eceee9); + border: 1px solid var(--border, #333); + border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5); + padding: 16px 18px 18px; +} +.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.draco-alertes-head h2 { margin: 0; font-size: 18px; } +.draco-alertes-close { + border: 1px solid var(--border, #333); background: transparent; + color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer; +} +.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; } +.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; } +.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; } +.draco-alertes-item { + border: 1px solid var(--border, #2a2a2a); border-radius: 10px; + padding: 9px 12px; margin: 6px 0; +} +.draco-alertes-item.is-active { border-color: #e8762c; } +.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; } +.draco-alertes-title:hover { text-decoration: underline; } +.draco-alertes-detail { + font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; } +.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; } +.draco-alertes-empty a { color: inherit; } diff --git a/frontend/draco-alertes.js b/frontend/draco-alertes.js new file mode 100644 index 0000000..b2aed2c --- /dev/null +++ b/frontend/draco-alertes.js @@ -0,0 +1,427 @@ +/* Alertes unifiées dracodev — Fusion #69 (chantier 2). + * + * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque + * ouverture du panneau (et au chargement, pour la pastille), le module + * interroge les API de notifications réellement exposées par chaque + * application, avec les identifiants du navigateur (credentials: + * "include"). Aucune alerte n'est inventée : une source injoignable + * ou non authentifiée affiche son état réel (« Connexion requise ») + * avec un lien profond vers sa page native, jamais du vide déguisé. + * + * Sources réelles (audit #69, 2026-10-28) : + * - Flowdeck : GET {base}/api/notifications?limit=50 + * -> { notifications: [{id, ntype, title, message, url, is_read, + * created_at}], unread } ; active = is_read = 0 ; lien = base + url. + * - TikTak : GET {base}/api/notifications + * -> [{id, kind, title, body, task_id, is_read, created_at}] ; + * active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon + * base + "/notifications". + * - Kanbante : GET {base}/api/events?limit=100 + * -> { events: [{repo_full_name, issue_number, event_type, + * from_column, to_column, created_at}] } ; AUCUN état lu/non lu + * n'existe dans Kanbante, donc tout y est historique et rien n'y + * est compté comme actif ; lien = base + "/board/" + repo. + * - ObsiGate : AUCUNE notification entrante consultable. Ses + * /api/notify/* sont des canaux d'envoi sortants (admin, secrets + * masqués), pas un journal lisible par l'utilisateur. La section + * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate. + * + * Résolution des bases (même ordre que barre-apps v0.1.6) : + * 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon + * window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ; + * 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ; + * 3. href rendu de l'onglet .draco-apptab[data-app] ; + * 4. défaut officiel (production). + * ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y + * lit aussi window.DRACO_URLS (injecté par son backend). + * Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique + * que sur un hôte -dev, exactement comme la barre. + * + * Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme + * seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts + * pour la pastille (repli : mise à jour directe du badge). + * Insertion des données : textContent uniquement, jamais innerHTML + * avec une valeur venue du serveur. + */ +(function (global) { + "use strict"; + + var DEFAULT_URLS = { + obsigate: "https://og.dracodev.net/", + flowdeck: "https://flowdeck.dracodev.net/", + tiktak: "https://tiktak.dracodev.net/", + kanbante: "https://kanbante.dracodev.net/" + }; + var DEV_URLS = { + obsigate: "https://og-dev.dracodev.net/", + flowdeck: "https://flowdeck-dev.dracodev.net/", + tiktak: "https://tiktak-dev.dracodev.net/", + kanbante: "https://kanbante-dev.dracodev.net/" + }; + var APP_LABELS = { + obsigate: "ObsiGate", flowdeck: "Flowdeck", + tiktak: "TikTak", kanbante: "Kanbante" + }; + var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; + var EVENT_LABELS = { + created: "créée", moved: "déplacée", closed: "fermée", + reopened: "rouverte", commented: "commentée" + }; + + function isDevHost(hostname) { + if (!hostname) return false; + var h = String(hostname).toLowerCase(); + if (h.slice(-13) !== ".dracodev.net") return false; + var first = h.split(".")[0]; + return first.length > 4 && first.slice(-4) === "-dev"; + } + function cleanUrl(u) { + if (!u || typeof u !== "string") return null; + if (u.indexOf("{{") !== -1) return null; + if (!u.replace(/\s/g, "")) return null; + return u; + } + function withSlash(u) { + return u.charAt(u.length - 1) !== "/" ? u + "/" : u; + } + function joinUrl(base, path) { + var b = withSlash(base); + if (!path) return b; + if (/^https?:\/\//.test(path)) return path; + return b + String(path).replace(/^\//, ""); + } + function parseDate(s) { + if (!s) return null; + var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1 + ? String(s).replace(" ", "T") + "Z" : s); + return isNaN(t.getTime()) ? null : t; + } + function fmtDate(s) { + var t = parseDate(s); + if (!t) return s ? String(s).slice(0, 16) : ""; + try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); } + } + + /* Résolution des bases. `env` permet les tests sans navigateur : + * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */ + function resolveUrls(env) { + env = env || {}; + var hostname = env.hostname; + if (hostname === undefined) { + try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; } + } + var dev = isDevHost(hostname); + var urls = {}; + APP_ORDER.forEach(function (app) { + var u = cleanUrl(env.barUrls && env.barUrls[app]) + || cleanUrl(env.configUrls && env.configUrls[app]); + if (!u && dev) u = DEV_URLS[app] || null; + if (!u) { + var href = null; + if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app]; + else if (typeof document !== "undefined" && document.querySelector) { + var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]'); + href = tab ? tab.getAttribute("href") : null; + } + u = cleanUrl(href); + } + if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]); + if (!u) { + /* ObsiGate : window.DRACO_URLS injecté par le backend. */ + try { + if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]); + } catch (e) { /* rien */ } + } + if (!u) u = DEFAULT_URLS[app]; + urls[app] = withSlash(u); + }); + return urls; + } + function browserEnv() { + var cfg = global.DRACO_BAR_CONFIG || {}; + var bar = global.DRACO_BAR || {}; + var tabs = {}; + try { + if (typeof document !== "undefined" && document.querySelectorAll) { + var nodes = document.querySelectorAll(".draco-apptab[data-app]"); + for (var i = 0; i < nodes.length; i++) { + tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href"); + } + } + } catch (e) { /* rien */ } + return { + barUrls: bar.urls || null, configUrls: cfg.urls || null, + tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null + }; + } + + function normalizeFlowdeck(base, payload) { + var list = (payload && payload.notifications) || []; + return list.map(function (n) { + return { + app: "flowdeck", id: n.id, title: n.title || "(sans titre)", + detail: n.message || "", date: n.created_at || "", + active: !n.is_read, state: n.is_read ? "lue" : "active", + kind: n.ntype || "", url: joinUrl(base, n.url || "") + }; + }); + } + function normalizeTiktak(base, payload) { + var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || []; + return list.map(function (n) { + return { + app: "tiktak", id: n.id, title: n.title || "(sans titre)", + detail: n.body || "", date: n.created_at || "", + active: !n.is_read, state: n.is_read ? "lue" : "active", + kind: n.kind || "", + url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications") + }; + }); + } + function normalizeKanbante(base, payload) { + var list = (payload && payload.events) || []; + return list.map(function (e) { + var label = EVENT_LABELS[e.event_type] || e.event_type || "événement"; + var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || ""); + return { + app: "kanbante", id: e.id, + title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label, + detail: detail, date: e.created_at || "", + active: false, state: label, kind: e.event_type || "", + url: joinUrl(base, "board/" + (e.repo_full_name || "")) + }; + }); + } + /* Actives d'abord, puis date décroissante (sans date en dernier). */ + function sortAlerts(list) { + return list.slice().sort(function (a, b) { + if (a.active !== b.active) return a.active ? -1 : 1; + var da = parseDate(a.date), db = parseDate(b.date); + if (da && db) return db - da; + if (da) return -1; + if (db) return 1; + return 0; + }); + } + function countActive(list) { + var n = 0; + list.forEach(function (a) { if (a.active) n++; }); + return n; + } + + /* ---- Rendu (navigateur seulement) ---- */ + var panel = null, listEl = null, badgeTimer = null; + + function el(tag, cls, text) { + var d = document.createElement(tag); + if (cls) d.className = cls; + if (text !== undefined && text !== null) d.textContent = text; + return d; + } + function ensurePanel() { + if (panel) return panel; + var ov = el("div", "draco-alertes-overlay"); + ov.setAttribute("role", "dialog"); + ov.setAttribute("aria-label", "Alertes unifiées"); + var box = el("div", "draco-alertes-box"); + var head = el("div", "draco-alertes-head"); + head.appendChild(el("h2", "", "Alertes unifiées")); + var note = el("p", "draco-alertes-note", + "Agrégation à la demande, pas une fédération temps réel : " + + "les alertes sont relues dans chaque application à l'ouverture."); + var close = el("button", "draco-alertes-close", "✕"); + close.setAttribute("aria-label", "Fermer"); + close.addEventListener("click", closePanel); + head.appendChild(close); + box.appendChild(head); + box.appendChild(note); + listEl = el("div", "draco-alertes-list"); + box.appendChild(listEl); + ov.appendChild(box); + ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); }); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape") closePanel(); + }); + document.body.appendChild(ov); + panel = ov; + return ov; + } + function closePanel() { if (panel) panel.style.display = "none"; } + + function renderSection(title, items, fallback) { + var sec = el("section", "draco-alertes-section"); + sec.appendChild(el("h3", "", title + " (" + items.length + ")")); + if (!items.length) { + sec.appendChild(el("p", "draco-alertes-empty", fallback)); + return sec; + } + items.forEach(function (a) { + var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : "")); + var main = el("div", "draco-alertes-main"); + var link = el("a", "draco-alertes-title", a.title); + link.href = a.url; + main.appendChild(link); + if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail)); + var meta = el("div", "draco-alertes-meta", + APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") + + " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state); + main.appendChild(meta); + row.appendChild(main); + sec.appendChild(row); + }); + return sec; + } + function render(results, urls) { + ensurePanel(); + listEl.textContent = ""; + var all = []; + APP_ORDER.forEach(function (app) { + (results[app] && results[app].items || []).forEach(function (a) { all.push(a); }); + }); + var actives = sortAlerts(all.filter(function (a) { return a.active; })); + var history = sortAlerts(all.filter(function (a) { return !a.active; })); + listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives")); + APP_ORDER.forEach(function (app) { + var items = actives.filter(function (a) { return a.app === app; }); + if (results[app] && results[app].error) { + var sec = renderSection(APP_LABELS[app], [], results[app].error); + listEl.appendChild(sec); + } else if (app === "obsigate") { + listEl.appendChild(renderSection(APP_LABELS[app], [], + "ObsiGate n'expose aucune notification entrante consultable " + + "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs).")); + } else if (items.length) { + listEl.appendChild(renderSection(APP_LABELS[app], items, "")); + } + }); + if (!actives.length) { + listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables.")); + } + listEl.appendChild(el("h3", "draco-alertes-group", "Historique")); + APP_ORDER.forEach(function (app) { + if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */ + if (app === "obsigate") { + var link = el("div", "draco-alertes-empty"); + var a = el("a", "", "Ouvrir ObsiGate"); + a.href = urls.obsigate; + link.appendChild(a); + var sec = el("section", "draco-alertes-section"); + sec.appendChild(el("h3", "", "ObsiGate (0)")); + sec.appendChild(link); + listEl.appendChild(sec); + return; + } + var items = history.filter(function (x) { return x.app === app; }); + listEl.appendChild(renderSection(APP_LABELS[app], items, + "Aucun historique disponible pour cette application.")); + }); + } + + function fetchJson(url, timeoutMs) { + var ctrl = null, timer = null; + try { + if (typeof AbortController !== "undefined") { + ctrl = new AbortController(); + timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000); + } + } catch (e) { ctrl = null; } + var opts = { credentials: "include" }; + if (ctrl) opts.signal = ctrl.signal; + return fetch(url, opts).then(function (r) { + if (timer) clearTimeout(timer); + if (!r.ok) { + var err = new Error("HTTP " + r.status); + err.status = r.status; + throw err; + } + return r.json(); + }).catch(function (e) { + if (timer) clearTimeout(timer); + throw e; + }); + } + function loadAll() { + var urls = resolveUrls(browserEnv()); + var jobs = { + flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50") + .then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }), + tiktak: fetchJson(urls.tiktak + "api/notifications") + .then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }), + kanbante: fetchJson(urls.kanbante + "api/events?limit=100") + .then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; }) + }; + var keys = Object.keys(jobs); + return Promise.all(keys.map(function (k) { + return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) { + var msg = (e && e.status === 401) + ? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau." + : "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ")."; + return [k, { items: [], error: msg }]; + }); + })).then(function (pairs) { + var results = { obsigate: { items: [] } }; + pairs.forEach(function (kv) { results[kv[0]] = kv[1]; }); + return { urls: urls, results: results }; + }); + } + function setBadge(n) { + try { + if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") { + global.DRACO_BAR.setCounts({ alertes: n }); + return; + } + } catch (e) { /* repli ci-dessous */ } + try { + document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) { + b.textContent = n; + b.hidden = !(n > 0); + }); + } catch (e) { /* rien */ } + } + function refreshBadge() { + return loadAll().then(function (r) { + var all = []; + APP_ORDER.forEach(function (app) { + (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); + }); + setBadge(countActive(all)); + return countActive(all); + }).catch(function () { /* pastille optionnelle : la barre vit sans */ }); + } + function openPanel() { + ensurePanel(); + panel.style.display = "flex"; + listEl.textContent = ""; + listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); + loadAll().then(function (r) { + render(r.results, r.urls); + var all = []; + APP_ORDER.forEach(function (app) { + (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); + }); + setBadge(countActive(all)); + }).catch(function () { + render({ obsigate: { items: [] } }, resolveUrls(browserEnv())); + }); + } + + var api = { + open: openPanel, close: closePanel, refreshBadge: refreshBadge, + _test: { + resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, + normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, + sortAlerts: sortAlerts, countActive: countActive, + isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS + } + }; + global.DRACO_ALERTES = api; + if (typeof module !== "undefined" && module.exports) module.exports = api._test; + + /* Pastille au chargement (après les scripts defer de la barre). */ + if (typeof document !== "undefined" && document.addEventListener) { + document.addEventListener("DOMContentLoaded", function () { + if (badgeTimer) clearTimeout(badgeTimer); + badgeTimer = setTimeout(refreshBadge, 300); + }); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/frontend/index.html b/frontend/index.html index f5b5988..2d86d58 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -76,6 +76,7 @@ + + diff --git a/tests/frontend/draco-alertes.test.mjs b/tests/frontend/draco-alertes.test.mjs new file mode 100644 index 0000000..53684a7 --- /dev/null +++ b/tests/frontend/draco-alertes.test.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * ObsiGate — Alertes unifiées (Fusion #69, chantier 2). + * + * Tests du module partagé frontend/draco-alertes.js (logique pure, + * sans DOM) + vérification du gabarit livré frontend/index.html : + * le placeholder « Alertes unifiées : arrive avec la fédération » + * a disparu et le module est chargé. + * + * Usage: node tests/frontend/draco-alertes.test.mjs + */ +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const require = createRequire(import.meta.url); +const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js")); + +test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => { + assert.equal( + t.resolveUrls({ hostname: "og.dracodev.net", tabHrefs: {} }).tiktak, + "https://tiktak.dracodev.net/" + ); + assert.equal( + t.resolveUrls({ hostname: "og-dev.dracodev.net", tabHrefs: {} }).flowdeck, + "https://flowdeck-dev.dracodev.net/" + ); + assert.equal( + t.resolveUrls({ + hostname: "og-dev.dracodev.net", + configUrls: { kanbante: "https://kanbante.dracodev.net/" }, + }).kanbante, + "https://kanbante.dracodev.net/" + ); + assert.equal(t.isDevHost("og-dev.dracodev.net.evil.com"), false); +}); + +test("normalizeFlowdeck/TikTak/Kanbante: actifs = non lus, Kanbante jamais actif", () => { + const fd = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", { + notifications: [ + { id: 1, ntype: "mention", title: "Mention", message: "m", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" }, + ], + unread: 1, + }); + assert.equal(fd[0].active, true); + assert.equal(fd[0].url, "https://flowdeck.dracodev.net/collections/3"); + const tt = t.normalizeTiktak("https://tiktak.dracodev.net/", [ + { id: 7, kind: "reminder", title: "Rappel", body: "", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" }, + ]); + assert.equal(tt[0].url, "https://tiktak.dracodev.net/?task=42"); + const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", { + events: [ + { id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" }, + ], + }); + assert.equal(kb[0].active, false); + assert.equal(kb[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps"); + assert.equal(t.countActive([...fd, ...tt, ...kb]), 2); +}); + +test("sortAlerts: actives d'abord, date decroissante", () => { + const sorted = t.sortAlerts([ + { app: "tiktak", active: false, date: "2026-10-28T12:00:00" }, + { app: "flowdeck", active: true, date: "2026-10-26T12:00:00" }, + { app: "flowdeck", active: true, date: "2026-10-28T12:00:00" }, + ]); + assert.equal(sorted[0].active, true); + assert.equal(sorted[0].date, "2026-10-28T12:00:00"); +}); + +test("index.html: placeholder Alertes disparu, module charge", () => { + const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8"); + assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false); + assert.equal(html.includes("/static/draco-alertes.js"), true); + assert.equal(html.includes("/static/draco-alertes.css"), true); + assert.equal(html.includes("DRACO_ALERTES.open()"), true); +});