Skip to main content

forge_connectors/
gotify.rs

1//! The Gotify connector: WebSocket receive stream, `/application` +
2//! `/message` REST lookups, the subscription matcher, and the
3//! reconnecting subsystem pump that routes matched messages into the
4//! host's sessions.
5//!
6//! Everything the connector needs from the workspace arrives through
7//! the [`GotifyHost`] port, so this module stays stream + mapping and
8//! is testable offline.
9
10use std::collections::HashMap;
11use std::time::{Duration, Instant};
12
13use anyhow::Context;
14use forge_primitives::{GotifyConfig, GotifyMessage, GotifySubscription};
15use futures::{SinkExt, StreamExt};
16use serde::Deserialize;
17use tokio::net::TcpStream;
18use tokio::sync::{mpsc, oneshot};
19use tokio_tungstenite::tungstenite::{Bytes, Message};
20use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
21
22/// The host port, implemented by forge-workspace. The only
23/// workspace-side state, delivery or TLS-trust plumbing a connector
24/// may reach, so this crate stays stream + mapping.
25pub trait GotifyHost: Send + Sync {
26    /// A reqwest client with the NODE_EXTRA_CA_CERTS roots applied
27    /// and the caller's timeout baked in.
28    fn http_client(&self, timeout: Duration) -> Result<reqwest::Client, String>;
29
30    /// The active subscriptions to match inbound messages against.
31    fn subscriptions(&self) -> Vec<GotifySubscription>;
32
33    /// The application NAME for `appid` via the app index, or `None`
34    /// when the id isn't known (index not yet fetched, or a new app
35    /// the server added after the last refresh).
36    fn app_name(&self, appid: u64) -> Option<String>;
37
38    /// Replace the fetched name->appid index.
39    fn store_app_index(&self, index: HashMap<String, u64>);
40
41    /// Record stream liveness for the Inspector's GOTIFY status line.
42    fn set_connected(&self, connected: bool);
43
44    /// Deliver one matched message to its subscriber's session:
45    /// dispatch a user-turn into the running target, or buffer + spawn
46    /// the asleep one.
47    fn deliver(&self, subscription: &GotifySubscription, app: &str, message: &GotifyMessage);
48}
49
50/// Reconnect backoff: starts at 500ms, doubles per failed/short-lived
51/// connect, caps at 30s, and resets to the floor only after a connection
52/// proves healthy (see [`MIN_HEALTHY_UPTIME`]).
53const BACKOFF_START: Duration = Duration::from_millis(500);
54const BACKOFF_CAP: Duration = Duration::from_secs(30);
55
56/// Minimum time a connection must stay up to count as healthy. A session
57/// that drops sooner keeps the backoff escalating, so an accept-then-drop
58/// server (post-upgrade auth reject, an idle-conn-dropping proxy) can't
59/// spin at the 500ms floor.
60const MIN_HEALTHY_UPTIME: Duration = Duration::from_secs(5);
61
62/// Dial timeout so a stuck `connect_async` (no OS-level deadline) can't
63/// hang the loop; the dial is also raced against shutdown so a signal is
64/// observed promptly rather than bounded by the OS TCP timeout.
65const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
66
67/// Send a WebSocket keepalive ping this often on an established stream, so a
68/// half-open path surfaces as an unanswered ping (write error or no returning
69/// pong) instead of a silently blocked read that never wakes.
70const PING_INTERVAL: Duration = Duration::from_secs(22);
71
72/// Treat an established stream's path as dead when no frame (message, server
73/// ping, or pong) arrives within this window. Sits comfortably past both 2x
74/// [`PING_INTERVAL`] and Gotify's own ~45s server-ping period, so a healthy
75/// but quiet stream never trips it; a half-open socket left by a silent
76/// network drop does, which breaks the read loop into a reconnect.
77const IDLE_DEADLINE: Duration = Duration::from_secs(80);
78
79/// Per-request timeout for the read-only REST lookups (`/application`,
80/// `/message`) - keep a slow or unreachable server from stalling
81/// subsystem start or a tool call.
82const REST_TIMEOUT: Duration = Duration::from_secs(10);
83
84/// Newest `/message` window pulled before client-side app + priority
85/// filtering. Gotify's `/message` filters by neither, so we fetch the
86/// newest page and narrow locally; 200 is its per-request maximum.
87const RECENT_FETCH_WINDOW: u32 = 200;
88
89/// Connection-lifecycle + message events from the stream task to the
90/// subsystem pump.
91#[derive(Debug)]
92enum GotifyEvent {
93    Connected,
94    Disconnected,
95    Message(GotifyMessage),
96}
97
98/// One entry of Gotify's `GET /application` response. Fields beyond
99/// `id` + `name` (token, description, image) are ignored.
100#[derive(Debug, Deserialize)]
101struct GotifyApp {
102    id: u64,
103    name: String,
104}
105
106/// Gotify's `GET /message` paged response. Only the newest page of
107/// `messages` is consumed; the `paging` cursor is ignored - a catch-up
108/// read wants the newest N, not the full history.
109#[derive(Debug, Deserialize)]
110struct GotifyMessages {
111    messages: Vec<GotifyMessage>,
112}
113
114/// One catch-up notification from `GET /message`, resolved for a
115/// `gotify__recent` reply: the application display name (from the appid,
116/// or the id as a string when the app index hasn't seen it), title,
117/// message body, priority, and the server's verbatim RFC3339 timestamp.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct GotifyRecent {
120    pub app: String,
121    pub title: String,
122    pub message: String,
123    pub priority: u8,
124    pub date: String,
125}
126
127/// A reqwest client for the read-only REST lookups, carrying the shared
128/// native-roots trust extension every forge HTTPS site uses.
129fn rest_client(host: &dyn GotifyHost) -> anyhow::Result<reqwest::Client> {
130    host.http_client(REST_TIMEOUT)
131        .map_err(|error| anyhow::anyhow!("build Gotify http client: {error}"))
132}
133
134/// GET the server's application list. Shared by the name->id index, the
135/// `gotify__apps` name list, and `gotify__recent`'s appid->name resolution.
136async fn fetch_apps(host: &dyn GotifyHost, cfg: &GotifyConfig) -> anyhow::Result<Vec<GotifyApp>> {
137    let url = format!("{}/application", cfg.url.trim_end_matches('/'));
138    rest_client(host)?
139        .get(url)
140        .header("X-Gotify-Key", &cfg.client_token)
141        .send()
142        .await
143        .context("GET /application")?
144        .error_for_status()
145        .context("/application returned an error status")?
146        .json()
147        .await
148        .context("parse /application body")
149}
150
151/// Fetch the server's application list and fold it into a name->appid
152/// map. Inbound stream messages carry the numeric appid, so the map
153/// resolves an `application` name filter to the id to match against.
154pub async fn app_index(
155    host: &dyn GotifyHost,
156    cfg: &GotifyConfig,
157) -> anyhow::Result<HashMap<String, u64>> {
158    Ok(build_app_index(fetch_apps(host, cfg).await?))
159}
160
161fn build_app_index(apps: Vec<GotifyApp>) -> HashMap<String, u64> {
162    apps.into_iter().map(|app| (app.name, app.id)).collect()
163}
164
165/// The server's application NAMEs (from `GET /application`), in the order
166/// the server returns them. Backs `gotify__apps` so a session can
167/// self-discover which apps it may subscribe to.
168pub async fn app_names(host: &dyn GotifyHost, cfg: &GotifyConfig) -> anyhow::Result<Vec<String>> {
169    Ok(build_app_names(fetch_apps(host, cfg).await?))
170}
171
172fn build_app_names(apps: Vec<GotifyApp>) -> Vec<String> {
173    apps.into_iter().map(|app| app.name).collect()
174}
175
176fn build_id_index(apps: Vec<GotifyApp>) -> HashMap<u64, String> {
177    apps.into_iter().map(|app| (app.id, app.name)).collect()
178}
179
180/// The most recent notifications, newest first, filtered by application
181/// NAME (empty = any) and `min_priority` (`None` = any), capped at `limit`.
182/// Fetches the newest `/message` window plus `/application` (for appid->
183/// name), then narrows locally - Gotify's `/message` filters by neither.
184pub async fn recent_messages(
185    host: &dyn GotifyHost,
186    cfg: &GotifyConfig,
187    applications: &[String],
188    min_priority: Option<u8>,
189    limit: usize,
190) -> anyhow::Result<Vec<GotifyRecent>> {
191    let messages = fetch_messages(host, cfg, RECENT_FETCH_WINDOW).await?;
192    let id_index = build_id_index(fetch_apps(host, cfg).await?);
193    Ok(filter_recent(messages, &id_index, applications, min_priority, limit))
194}
195
196async fn fetch_messages(
197    host: &dyn GotifyHost,
198    cfg: &GotifyConfig,
199    limit: u32,
200) -> anyhow::Result<Vec<GotifyMessage>> {
201    let url = format!("{}/message?limit={limit}", cfg.url.trim_end_matches('/'));
202    let page: GotifyMessages = rest_client(host)?
203        .get(url)
204        .header("X-Gotify-Key", &cfg.client_token)
205        .send()
206        .await
207        .context("GET /message")?
208        .error_for_status()
209        .context("/message returned an error status")?
210        .json()
211        .await
212        .context("parse /message body")?;
213    Ok(page.messages)
214}
215
216/// Resolve appid->name, filter by app + priority, sort newest-first (by
217/// monotonic id), and truncate to `limit`. App matching mirrors `route`:
218/// an unresolved appid never matches a
219/// non-empty name filter (its numeric display string can't sneak past),
220/// though its display `app` still falls back to that id string.
221fn filter_recent(
222    mut messages: Vec<GotifyMessage>,
223    id_index: &HashMap<u64, String>,
224    applications: &[String],
225    min_priority: Option<u8>,
226    limit: usize,
227) -> Vec<GotifyRecent> {
228    messages.sort_unstable_by_key(|m| std::cmp::Reverse(m.id));
229    messages
230        .into_iter()
231        .filter_map(|msg| {
232            let resolved = id_index.get(&msg.appid);
233            let priority_ok = min_priority.is_none_or(|floor| msg.priority >= floor);
234            let app_ok =
235                applications.is_empty() || resolved.is_some_and(|name| applications.contains(name));
236            if !(priority_ok && app_ok) {
237                return None;
238            }
239            Some(GotifyRecent {
240                app: resolved.cloned().unwrap_or_else(|| msg.appid.to_string()),
241                title: msg.title,
242                message: msg.message,
243                priority: msg.priority,
244                date: msg.date,
245            })
246        })
247        .take(limit)
248        .collect()
249}
250
251/// Parse a raw stream Text frame into a typed [`GotifyMessage`].
252fn normalize(text: &str) -> anyhow::Result<GotifyMessage> {
253    serde_json::from_str(text).context("parse Gotify stream message")
254}
255
256/// The subscriptions whose `min_priority` and `applications` filters
257/// `priority` and `resolved_app` satisfy, in set order. `resolved_app`
258/// is the message's application NAME resolved from the app index; an
259/// unresolved appid matches only an empty (match-any) filter set, never
260/// a filter that happens to list the id numerically.
261pub fn matching_subscriptions<'a>(
262    subs: &'a [GotifySubscription],
263    resolved_app: Option<&str>,
264    priority: u8,
265) -> Vec<&'a GotifySubscription> {
266    subs.iter()
267        .filter(|sub| {
268            let priority_ok = sub.min_priority.is_none_or(|floor| priority >= floor);
269            let app_ok = sub.applications.is_empty()
270                || resolved_app.is_some_and(|name| sub.applications.iter().any(|app| app == name));
271            priority_ok && app_ok
272        })
273        .collect()
274}
275
276/// An open Gotify receive stream.
277struct GotifyStream {
278    ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
279}
280
281/// How a pumped stream ended, steering the reconnect loop.
282enum PumpOutcome {
283    /// Shutdown fired or the event receiver went away - stop the run loop.
284    Stop,
285    /// The stream ended, errored, or went idle past the deadline - fall
286    /// through to the reconnect backoff.
287    Reconnect,
288}
289
290impl GotifyStream {
291    /// Open `{ws,wss}://<host>/stream?token=<client_token>` - `wss` for
292    /// an `https` url, `ws` for `http`.
293    async fn connect(cfg: &GotifyConfig) -> anyhow::Result<Self> {
294        let url = stream_url(cfg)?;
295        let (ws, _resp) = connect_async(url.as_str()).await.context("connect Gotify stream")?;
296        Ok(Self { ws })
297    }
298
299    /// Forward frames to `tx` until shutdown, a read error/close, or a dead
300    /// path (no frame within [`IDLE_DEADLINE`]). Splits the socket so a
301    /// keepalive ping and the read run concurrently: any inbound frame counts
302    /// as liveness, so a healthy-but-quiet stream stays up while a half-open
303    /// one is detected and dropped for the reconnect loop to redial. Text
304    /// frames forward as messages; an unparseable one is skipped with a warn.
305    async fn pump(
306        self,
307        tx: &mpsc::Sender<GotifyEvent>,
308        shutdown: &mut oneshot::Receiver<()>,
309    ) -> PumpOutcome {
310        let (mut sink, mut frames) = self.ws.split();
311        let mut ping = tokio::time::interval(PING_INTERVAL);
312        // The first tick fires immediately; drop it so pings pace from now.
313        ping.tick().await;
314        let mut last_activity = Instant::now();
315        loop {
316            tokio::select! {
317                _ = &mut *shutdown => return PumpOutcome::Stop,
318                _ = ping.tick() => {
319                    if sink.send(Message::Ping(Bytes::new())).await.is_err() {
320                        return PumpOutcome::Reconnect;
321                    }
322                    if idle_past_deadline(last_activity, Instant::now()) {
323                        tracing::warn!(
324                            target: "forge_connectors::gotify",
325                            idle_secs = IDLE_DEADLINE.as_secs(),
326                            "Gotify stream idle past deadline; treating path as dead",
327                        );
328                        return PumpOutcome::Reconnect;
329                    }
330                }
331                frame = frames.next() => match frame {
332                    Some(Ok(Message::Text(text))) => {
333                        last_activity = Instant::now();
334                        match normalize(text.as_str()) {
335                            Ok(msg) => {
336                                if tx.send(GotifyEvent::Message(msg)).await.is_err() {
337                                    return PumpOutcome::Stop;
338                                }
339                            }
340                            Err(error) => {
341                                tracing::warn!(target: "forge_connectors::gotify", %error, "skipping unparseable stream frame");
342                            }
343                        }
344                    }
345                    Some(Ok(_)) => last_activity = Instant::now(),
346                    Some(Err(error)) => {
347                        tracing::warn!(target: "forge_connectors::gotify", %error, "Gotify stream read error");
348                        return PumpOutcome::Reconnect;
349                    }
350                    None => return PumpOutcome::Reconnect,
351                }
352            }
353        }
354    }
355}
356
357fn stream_url(cfg: &GotifyConfig) -> anyhow::Result<String> {
358    let base = cfg.url.trim_end_matches('/');
359    let ws_base = if let Some(rest) = base.strip_prefix("https://") {
360        format!("wss://{rest}")
361    } else if let Some(rest) = base.strip_prefix("http://") {
362        format!("ws://{rest}")
363    } else {
364        anyhow::bail!("Gotify url must start with http:// or https://: {}", cfg.url);
365    };
366    Ok(format!("{ws_base}/stream?token={}", cfg.client_token))
367}
368
369/// Next reconnect delay after a session ended: reset to the floor when the
370/// connection proved healthy (stayed up past [`MIN_HEALTHY_UPTIME`]), else
371/// escalate (double, capped at [`BACKOFF_CAP`]).
372fn next_backoff(current: Duration, healthy: bool) -> Duration {
373    if healthy { BACKOFF_START } else { (current * 2).min(BACKOFF_CAP) }
374}
375
376/// Whether the stream has gone silent past [`IDLE_DEADLINE`] as of `now` -
377/// no inbound frame refreshed `last_activity` in time, so the path is treated
378/// as dead. Any frame resets `last_activity`, which resets this.
379fn idle_past_deadline(last_activity: Instant, now: Instant) -> bool {
380    now.saturating_duration_since(last_activity) > IDLE_DEADLINE
381}
382
383/// Long-lived reconnect loop: connect (emit [`GotifyEvent::Connected`]),
384/// forward each message as [`GotifyEvent::Message`], and on drop/error
385/// emit [`GotifyEvent::Disconnected`] then retry with exponential backoff.
386/// The backoff resets to the floor only after a healthy session (one that
387/// stayed up past `MIN_HEALTHY_UPTIME`); a fast drop or failed dial keeps
388/// it escalating. Exits when `shutdown` fires or the sender is dropped.
389async fn run(
390    cfg: GotifyConfig,
391    tx: mpsc::Sender<GotifyEvent>,
392    mut shutdown: oneshot::Receiver<()>,
393) {
394    let mut backoff = BACKOFF_START;
395    loop {
396        // Race the dial against shutdown + a timeout so a stuck connect
397        // can neither outlive a shutdown signal nor hang the loop.
398        let dial = tokio::select! {
399            _ = &mut shutdown => return,
400            result = tokio::time::timeout(CONNECT_TIMEOUT, GotifyStream::connect(&cfg)) => result,
401        };
402        let healthy = match dial {
403            Ok(Ok(stream)) => {
404                let connected_at = tokio::time::Instant::now();
405                if tx.send(GotifyEvent::Connected).await.is_err() {
406                    return;
407                }
408                if let PumpOutcome::Stop = stream.pump(&tx, &mut shutdown).await {
409                    return;
410                }
411                let _ = tx.send(GotifyEvent::Disconnected).await;
412                connected_at.elapsed() >= MIN_HEALTHY_UPTIME
413            }
414            Ok(Err(error)) => {
415                tracing::warn!(target: "forge_connectors::gotify", %error, "Gotify connect failed; backing off");
416                let _ = tx.send(GotifyEvent::Disconnected).await;
417                false
418            }
419            Err(_) => {
420                tracing::warn!(
421                    target: "forge_connectors::gotify",
422                    timeout_secs = CONNECT_TIMEOUT.as_secs(),
423                    "Gotify connect timed out; backing off",
424                );
425                let _ = tx.send(GotifyEvent::Disconnected).await;
426                false
427            }
428        };
429        backoff = next_backoff(backoff, healthy);
430        tokio::select! {
431            _ = &mut shutdown => return,
432            () = tokio::time::sleep(backoff) => {}
433        }
434    }
435}
436
437/// The Gotify subsystem pump: run the reconnecting stream task and
438/// translate its events into host state + message routing. Holds the
439/// host for its lifetime - the host impl is the weak boundary, so a
440/// workspace-backed host degrades to no-ops once the workspace drops;
441/// exits when `shutdown` fires or the stream task ends, dropping its
442/// own handle to the stream task so that exits too.
443pub async fn run_subsystem(
444    host: std::sync::Arc<dyn GotifyHost>,
445    cfg: GotifyConfig,
446    mut shutdown: oneshot::Receiver<()>,
447) {
448    let (tx, mut rx) = mpsc::channel::<GotifyEvent>(64);
449    // Dropping this handle at fn-end signals the stream task to exit.
450    let (_run_shutdown_tx, run_shutdown_rx) = oneshot::channel();
451    tokio::spawn(run(cfg.clone(), tx, run_shutdown_rx));
452
453    loop {
454        tokio::select! {
455            _ = &mut shutdown => break,
456            event = rx.recv() => {
457                let Some(event) = event else { break };
458                match event {
459                    GotifyEvent::Connected => {
460                        // Refresh the app index off the pump so a slow (up to
461                        // the 10s lookup timeout) or failing /application can't
462                        // block shutdown or message routing.
463                        let index_host = host.clone();
464                        let index_cfg = cfg.clone();
465                        tokio::spawn(async move {
466                            refresh_app_index(&*index_host, &index_cfg).await;
467                        });
468                        host.set_connected(true);
469                    }
470                    GotifyEvent::Disconnected => {
471                        host.set_connected(false);
472                    }
473                    GotifyEvent::Message(msg) => {
474                        route(&*host, &msg);
475                    }
476                }
477            }
478        }
479    }
480
481    host.set_connected(false);
482}
483
484/// Fetch the Gotify `/application` list and store the name->appid map.
485/// Warns (never silently drops) on failure - an unresolved index would
486/// otherwise leave every application-name-filtered subscription silently
487/// matching nothing while the stream still reports connected. Returns
488/// whether the index was stored, so a caller that owes the user an answer
489/// about the names can give one.
490pub async fn refresh_app_index(host: &dyn GotifyHost, cfg: &GotifyConfig) -> bool {
491    match app_index(host, cfg).await {
492        Ok(index) => {
493            host.store_app_index(index);
494            true
495        }
496        Err(error) => {
497            tracing::warn!(
498                target: "forge_connectors::gotify",
499                %error,
500                "Gotify /application lookup failed; application-name filters will not match until the next reconnect",
501            );
502            false
503        }
504    }
505}
506
507/// Match `msg` against every active subscription and deliver one
508/// message per match. Multiple matches fan out to every subscriber.
509fn route(host: &dyn GotifyHost, msg: &GotifyMessage) {
510    let subs = host.subscriptions();
511    let resolved_name = host.app_name(msg.appid);
512    // Matching uses the resolved name only; the delivered `app` falls
513    // back to the numeric id for display, so an unresolved appid can't
514    // match a filter that happens to list that number.
515    let display_name = resolved_name.clone().unwrap_or_else(|| msg.appid.to_string());
516    for sub in matching_subscriptions(&subs, resolved_name.as_deref(), msg.priority) {
517        host.deliver(sub, &display_name, msg);
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn normalize_parses_sample_message() {
527        let line = r#"{"id":1,"appid":3,"title":"t","message":"m","priority":5,"date":"2026-07-03T09:18:00Z"}"#;
528        let msg = normalize(line).expect("parse sample message");
529        assert_eq!(msg.appid, 3);
530        assert_eq!(msg.priority, 5);
531    }
532
533    #[test]
534    fn next_backoff_resets_on_healthy_and_escalates_otherwise() {
535        // A healthy session resets the ladder to the floor from anywhere.
536        assert_eq!(next_backoff(BACKOFF_CAP, true), BACKOFF_START);
537        assert_eq!(next_backoff(Duration::from_secs(8), true), BACKOFF_START);
538        // An unhealthy/failed connect doubles, capped at BACKOFF_CAP.
539        assert_eq!(next_backoff(BACKOFF_START, false), BACKOFF_START * 2);
540        assert_eq!(next_backoff(Duration::from_secs(20), false), BACKOFF_CAP);
541        assert_eq!(next_backoff(BACKOFF_CAP, false), BACKOFF_CAP);
542    }
543
544    #[test]
545    fn idle_past_deadline_trips_only_after_the_deadline() {
546        let base = Instant::now();
547        // At or before the deadline the stream is still considered live.
548        assert!(!idle_past_deadline(base, base + Duration::from_secs(1)));
549        assert!(!idle_past_deadline(base, base + IDLE_DEADLINE));
550        // Strictly past the deadline the path is treated as dead.
551        assert!(idle_past_deadline(base, base + IDLE_DEADLINE + Duration::from_secs(1)));
552    }
553
554    #[test]
555    fn fresh_activity_resets_the_idle_clock() {
556        let base = Instant::now();
557        // Long after connect, a frame moves last_activity forward; measured
558        // from that fresh timestamp the stream is nowhere near the deadline.
559        let refreshed = base + Duration::from_secs(10_000);
560        assert!(!idle_past_deadline(refreshed, refreshed + Duration::from_secs(1)));
561    }
562
563    #[test]
564    fn app_name_to_id_maps() {
565        let body = r#"[{"id":3,"name":"web-api","token":"A.abc","description":""}]"#;
566        let apps: Vec<GotifyApp> = serde_json::from_str(body).expect("parse application list");
567        let index = build_app_index(apps);
568        assert_eq!(index.get("web-api"), Some(&3));
569    }
570
571    #[test]
572    fn app_names_extracts_names_in_server_order() {
573        let apps = vec![
574            GotifyApp { id: 3, name: "web-api".to_owned() },
575            GotifyApp { id: 1, name: "Backups".to_owned() },
576        ];
577        assert_eq!(build_app_names(apps), vec!["web-api".to_owned(), "Backups".to_owned()]);
578    }
579
580    #[test]
581    fn message_page_parses_ignoring_extras_and_paging() {
582        let body = r#"{"messages":[{"id":25,"appid":1,"title":"t","message":"m","priority":4,"date":"2026-07-04T09:18:00Z","extras":{"x":1}}],"paging":{"size":1,"limit":100,"since":25}}"#;
583        let page: GotifyMessages = serde_json::from_str(body).expect("parse /message page");
584        assert_eq!(page.messages.len(), 1);
585        assert_eq!(page.messages[0].appid, 1);
586    }
587
588    fn msg(id: u64, appid: u64, priority: u8) -> GotifyMessage {
589        GotifyMessage {
590            id,
591            appid,
592            title: format!("t{id}"),
593            message: format!("m{id}"),
594            priority,
595            date: format!("2026-07-04T{id:02}:00:00Z"),
596        }
597    }
598
599    #[test]
600    fn filter_recent_orders_newest_first_and_truncates_to_limit() {
601        let index = HashMap::from([(1u64, "CI".to_owned())]);
602        // Deliberately out of order; filter_recent must sort newest-first.
603        let out =
604            filter_recent(vec![msg(1, 1, 5), msg(3, 1, 5), msg(2, 1, 5)], &index, &[], None, 2);
605        assert_eq!(out.len(), 2, "truncated to the limit");
606        assert_eq!(out[0].title, "t3", "highest id (newest) first");
607        assert_eq!(out[1].title, "t2");
608    }
609
610    #[test]
611    fn filter_recent_applies_app_name_and_priority_filters() {
612        let index = HashMap::from([(1u64, "CI".to_owned()), (2u64, "Backups".to_owned())]);
613        let msgs = vec![msg(10, 1, 8), msg(9, 2, 8), msg(8, 1, 2)];
614        let out = filter_recent(msgs, &index, &["CI".to_owned()], Some(5), 20);
615        assert_eq!(out.len(), 1, "only CI at priority >= 5 survives");
616        assert_eq!(out[0].app, "CI");
617        assert_eq!(out[0].priority, 8);
618    }
619
620    #[test]
621    fn filter_recent_unresolved_appid_shows_id_but_never_matches_a_named_filter() {
622        // appid 7 isn't in the index: its display `app` falls back to "7",
623        // but a filter naming "7" must NOT match it - mirrors the
624        // resolved-name-only matching in `route`.
625        let index = HashMap::from([(1u64, "CI".to_owned())]);
626        let unfiltered = filter_recent(vec![msg(5, 7, 9)], &index, &[], None, 20);
627        assert_eq!(unfiltered[0].app, "7", "unresolved appid displays as its id string");
628        let named = filter_recent(vec![msg(5, 7, 9)], &index, &["7".to_owned()], None, 20);
629        assert!(named.is_empty(), "a numeric-string filter can't match an unresolved appid");
630    }
631
632    fn sub(applications: &[&str], min_priority: Option<u8>) -> GotifySubscription {
633        GotifySubscription {
634            id: uuid::Uuid::new_v4(),
635            project: "p".to_owned(),
636            team_role: None,
637            applications: applications.iter().map(|s| (*s).to_owned()).collect(),
638            min_priority,
639            created_at: std::time::SystemTime::UNIX_EPOCH,
640        }
641    }
642
643    /// Every filter dimension at once: the priority floor, the app-name
644    /// set (matching ANY listed name), and the match-any empty set.
645    #[test]
646    fn matching_subscriptions_applies_priority_and_application_filters() {
647        let subs = [sub(&["alerts"], None), sub(&[], Some(5)), sub(&["alerts", "backups"], None)];
648        let matches = |resolved: Option<&str>, priority| {
649            matching_subscriptions(&subs, resolved, priority)
650                .iter()
651                .map(|s| s.id)
652                .collect::<Vec<_>>()
653        };
654
655        // alerts at priority 2: the app-filter subs match; the priority
656        // floor does not (2 < 5).
657        assert_eq!(matches(Some("alerts"), 2), [subs[0].id, subs[2].id]);
658        // The SECOND listed name matches the same set as the first, so a
659        // single-element comparison can't pass for the any-of filter.
660        assert_eq!(matches(Some("backups"), 1), [subs[2].id]);
661        // Below the floor only the match-any priority set survives.
662        assert_eq!(matches(Some("alerts"), 5), [subs[0].id, subs[1].id, subs[2].id]);
663        // An app outside every listed set leaves only the empty filter set.
664        assert_eq!(matches(Some("ci"), 9), [subs[1].id]);
665    }
666
667    /// The seam the route matcher pins: matching uses the RESOLVED app
668    /// name only, so an unresolved appid must not match a filter that
669    /// happens to list its numeric id as a string.
670    #[test]
671    fn matching_subscriptions_unresolved_appid_never_matches_a_numeric_filter() {
672        let subs = [sub(&["5"], None), sub(&[], None)];
673        let matches = matching_subscriptions(&subs, None, 1);
674        assert_eq!(matches.iter().map(|s| s.id).collect::<Vec<_>>(), [subs[1].id]);
675    }
676
677    /// Multiple matching subscriptions all come back: the caller fans a
678    /// message out to every subscriber.
679    #[test]
680    fn matching_subscriptions_returns_every_matching_subscription() {
681        let subs = [sub(&[], None), sub(&[], None), sub(&[], Some(9))];
682        assert_eq!(matching_subscriptions(&subs, None, 5).len(), 2);
683    }
684
685    /// Records `deliver` calls: `(project, team_role, app, priority)`.
686    type Delivery = (String, Option<String>, String, u8);
687
688    struct MockHost {
689        subs: Vec<GotifySubscription>,
690        index: HashMap<u64, String>,
691        delivered: std::sync::Mutex<Vec<Delivery>>,
692    }
693
694    impl GotifyHost for MockHost {
695        fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
696            Err("unused by route".to_owned())
697        }
698
699        fn subscriptions(&self) -> Vec<GotifySubscription> {
700            self.subs.clone()
701        }
702
703        fn app_name(&self, appid: u64) -> Option<String> {
704            self.index.get(&appid).cloned()
705        }
706
707        fn store_app_index(&self, _index: HashMap<String, u64>) {}
708
709        fn set_connected(&self, _connected: bool) {}
710
711        fn deliver(&self, subscription: &GotifySubscription, app: &str, message: &GotifyMessage) {
712            self.delivered.lock().expect("mock lock").push((
713                subscription.project.clone(),
714                subscription.team_role.clone(),
715                app.to_owned(),
716                message.priority,
717            ));
718        }
719    }
720
721    /// The seam between the matcher and the host: `route` matches on the
722    /// RESOLVED app name only, so an unresolved appid never satisfies a
723    /// filter listing its numeric id, while the delivered `app` still
724    /// falls back to that id string for display.
725    #[test]
726    fn route_matches_on_resolved_names_and_falls_back_to_the_id_for_display() {
727        let numeric_filter = sub(&["5"], None);
728        let match_any = sub(&[], None);
729        let host = MockHost {
730            subs: vec![numeric_filter, match_any],
731            index: HashMap::new(),
732            delivered: std::sync::Mutex::new(Vec::new()),
733        };
734
735        route(&host, &msg(1, 5, 1));
736
737        let delivered = host.delivered.lock().expect("mock lock").clone();
738        assert_eq!(
739            delivered.len(),
740            1,
741            "an unresolved appid matches only the empty filter set: {delivered:?}",
742        );
743        assert_eq!(delivered[0].2, "5", "the delivered app falls back to the id string");
744    }
745
746    /// Every matching subscriber receives one delivery, carrying its own
747    /// project + team role.
748    #[test]
749    fn route_fans_out_to_every_matching_subscriber() {
750        let lead = sub(&["alerts"], None);
751        let mut worker = sub(&[], Some(5));
752        worker.team_role = Some("reviewer".to_owned());
753        let host = MockHost {
754            subs: vec![lead, worker],
755            index: HashMap::from([(3u64, "alerts".to_owned())]),
756            delivered: std::sync::Mutex::new(Vec::new()),
757        };
758
759        // Priority 2 clears the app filter but not the floor.
760        route(&host, &msg(1, 3, 2));
761        assert_eq!(host.delivered.lock().expect("mock lock").len(), 1);
762
763        // Priority 5 clears both, so both subscribers deliver.
764        route(&host, &msg(2, 3, 5));
765        let delivered = host.delivered.lock().expect("mock lock").clone();
766        assert_eq!(delivered.len(), 3, "one more delivery on the second message");
767        assert_eq!(delivered[1].0, "p");
768        assert_eq!(delivered[2].1.as_deref(), Some("reviewer"));
769        assert_eq!(
770            delivered[1].2, "alerts",
771            "a resolved appid delivers its NAME, not the id string",
772        );
773    }
774}