Skip to main content

audd/
streams.rs

1//! Streams namespace — set/get callback URL, addStream/listStreams/setStreamUrl/deleteStream,
2//! longpoll with default-on preflight (and `skip_callback_check` opt-out),
3//! `derive_longpoll_category`, `parse_callback`.
4
5use std::collections::HashMap;
6use std::pin::Pin;
7
8use futures_core::Stream;
9use serde_json::Value;
10use tokio::sync::mpsc;
11use tokio::task::JoinHandle;
12
13use crate::client::{decode_or_raise, AudDInner};
14use crate::errors::{AudDError, ErrorKind};
15use crate::helpers::{add_return_to_url, derive_longpoll_category, parse_callback};
16use crate::http::HttpClient;
17use crate::models::{
18    CallbackEvent, Stream as StreamRow, StreamCallbackMatch, StreamCallbackNotification,
19};
20use crate::retry::{retry_async, RetryPolicy};
21
22/// Server returns error #19 with an "Internal error" message from
23/// `getCallbackUrl` when no callback URL is configured. Code 19 also covers
24/// real conditions (maintenance, blocked requests), so the preflight only
25/// treats it as the no-callback signal when the message indicates it — see
26/// [`indicates_no_callback_url`].
27const NO_CALLBACK_ERROR_CODE: i32 = 19;
28
29const HTTP_CLIENT_ERROR_FLOOR: u16 = 400;
30
31/// Margin added on top of the poll timeout when sizing the per-request HTTP
32/// deadline for longpoll GETs.
33const LONGPOLL_TIMEOUT_MARGIN_SECS: u64 = 10;
34
35/// Size the HTTP deadline for one longpoll request: the server-side poll
36/// timeout plus a network margin, so poll timeouts above the standard 60s
37/// aren't cut short by the transport.
38pub(crate) fn longpoll_request_timeout(poll_timeout_secs: i64) -> std::time::Duration {
39    let secs = u64::try_from(poll_timeout_secs).unwrap_or(0);
40    std::time::Duration::from_secs(secs + LONGPOLL_TIMEOUT_MARGIN_SECS)
41}
42
43/// Reports whether a code-19 error message from `getCallbackUrl` is the
44/// no-callback-URL signal ("Internal error") rather than a real server
45/// condition (maintenance, blocked request, abuse, ...).
46fn indicates_no_callback_url(message: &str) -> bool {
47    let lower = message.to_lowercase();
48    lower.contains("internal") || lower.contains("callback")
49}
50
51const PREFLIGHT_NO_CALLBACK_HINT: &str =
52    "Longpoll won't deliver events because no callback URL is configured for this account. \
53Set one first via streams.set_callback_url(...) — `https://audd.tech/empty/` is fine if \
54you only want longpolling and don't need a real receiver. \
55To skip this check, pass skip_callback_check=true.";
56
57/// Channel buffer for each of `matches` / `notifications` / `errors`. Small —
58/// we want to apply backpressure to the poll loop when the consumer is slow.
59const CHANNEL_BUFFER: usize = 16;
60
61/// Boxed stream alias kept inline (avoid pulling `futures_util::stream::BoxStream`
62/// just for the type name).
63type BoxStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
64
65/// Longpoll subscription configuration. Construct via [`Streams::longpoll`] and
66/// chain builder-style overrides.
67#[derive(Debug, Clone)]
68pub struct LongpollOptions {
69    since_time: Option<i64>,
70    timeout: i64,
71    skip_callback_check: bool,
72}
73
74impl Default for LongpollOptions {
75    fn default() -> Self {
76        Self {
77            since_time: None,
78            timeout: 50,
79            skip_callback_check: false,
80        }
81    }
82}
83
84impl LongpollOptions {
85    /// Set `since_time` (Unix-millis cursor returned by the server).
86    #[must_use]
87    pub fn since_time(mut self, t: i64) -> Self {
88        self.since_time = Some(t);
89        self
90    }
91
92    /// Set the per-request long-poll timeout in seconds (server-side cap; default 50).
93    #[must_use]
94    pub fn timeout(mut self, secs: i64) -> Self {
95        self.timeout = secs;
96        self
97    }
98
99    /// Skip the `getCallbackUrl` preflight check.
100    #[must_use]
101    pub fn skip_callback_check(mut self, skip: bool) -> Self {
102        self.skip_callback_check = skip;
103        self
104    }
105}
106
107/// An active longpoll subscription. Three typed streams surface its output:
108///
109/// * [`Self::matches`] — recognition matches.
110/// * [`Self::notifications`] — stream-lifecycle events.
111/// * [`Self::errors`] — yields a single terminal error then closes; after an
112///   error fires, `matches` and `notifications` close too.
113///
114/// Drop the [`LongpollPoll`] (or call [`Self::close`]) to tear down the
115/// background poller.
116pub struct LongpollPoll {
117    /// Recognition matches.
118    pub matches: BoxStream<StreamCallbackMatch>,
119    /// Stream-lifecycle notifications (e.g. `stream stopped`, `can't connect`).
120    pub notifications: BoxStream<StreamCallbackNotification>,
121    /// Terminal-error stream — yields at most one error and closes.
122    pub errors: BoxStream<AudDError>,
123
124    shutdown: Option<mpsc::Sender<()>>,
125    join: Option<JoinHandle<()>>,
126}
127
128impl std::fmt::Debug for LongpollPoll {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("LongpollPoll").finish_non_exhaustive()
131    }
132}
133
134impl LongpollPoll {
135    /// Stop the background poll and wait for it to drain. Idempotent (only
136    /// the first call performs the shutdown; subsequent calls are no-ops).
137    pub async fn close(mut self) {
138        self.close_internal().await;
139    }
140
141    async fn close_internal(&mut self) {
142        // Drop the sender to signal shutdown.
143        self.shutdown.take();
144        if let Some(handle) = self.join.take() {
145            let _ = handle.await;
146        }
147    }
148}
149
150impl Drop for LongpollPoll {
151    fn drop(&mut self) {
152        // On drop, take the sender so the background task exits its loop.
153        // We don't await the join handle — best-effort cleanup. Callers who
154        // want deterministic shutdown should call `close().await` first.
155        self.shutdown.take();
156        if let Some(handle) = self.join.take() {
157            handle.abort();
158        }
159    }
160}
161
162/// Streams namespace. Reach via [`crate::AudD::streams`].
163pub struct Streams<'a> {
164    inner: &'a AudDInner,
165}
166
167impl<'a> Streams<'a> {
168    pub(crate) fn new(inner: &'a AudDInner) -> Self {
169        Self { inner }
170    }
171
172    /// Set the callback URL on the caller's account. If `return_metadata` is
173    /// provided, it's appended as a `?return=...` query parameter to the URL.
174    /// Refuses to silently overwrite an existing `return=` parameter.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`AudDError`] for transport, server, or input-conflict failures.
179    pub async fn set_callback_url(
180        &self,
181        url: &str,
182        return_metadata: Option<&[String]>,
183        extra_parameters: Option<&HashMap<String, String>>,
184    ) -> Result<(), AudDError> {
185        let url = add_return_to_url(url, return_metadata)?;
186        // extra_parameters first; typed fields (`url`) win on collision.
187        let mut fields: Vec<(&str, String)> = Vec::new();
188        if let Some(extras) = extra_parameters {
189            for (k, v) in extras {
190                fields.push((k.as_str(), v.clone()));
191            }
192        }
193        fields.push(("url", url));
194        post_form(
195            self.inner,
196            "setCallbackUrl",
197            &format!("{}/setCallbackUrl/", self.inner.api_base),
198            &fields,
199            self.inner.mutating_policy(),
200        )
201        .await
202        .map(drop)
203    }
204
205    /// Read the currently-configured callback URL.
206    ///
207    /// Returns `Ok(None)` when the server reports success with a `null`
208    /// result (no URL value to return).
209    ///
210    /// # Errors
211    ///
212    /// Returns [`AudDError::Api`] with code 19 if no callback URL is configured.
213    pub async fn get_callback_url(&self) -> Result<Option<String>, AudDError> {
214        let result = post_form(
215            self.inner,
216            "getCallbackUrl",
217            &format!("{}/getCallbackUrl/", self.inner.api_base),
218            &[],
219            self.inner.read_policy(),
220        )
221        .await?;
222        if result.is_null() {
223            return Ok(None);
224        }
225        Ok(Some(
226            result
227                .as_str()
228                .map_or_else(|| result.to_string(), str::to_string),
229        ))
230    }
231
232    /// Add a stream subscription.
233    ///
234    /// `url` accepts direct stream URLs (DASH, Icecast, HLS, m3u/m3u8) and
235    /// shortcuts like `twitch:<channel>`, `youtube:<video_id>`,
236    /// `youtube-ch:<channel_id>`. Pass `callbacks=Some("before")` to deliver
237    /// callbacks at song start instead of song end.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`AudDError`] for transport/server failures.
242    pub async fn add(
243        &self,
244        url: &str,
245        radio_id: i64,
246        callbacks: Option<&str>,
247        extra_parameters: Option<&HashMap<String, String>>,
248    ) -> Result<(), AudDError> {
249        // extra_parameters first; typed fields win on collision.
250        let mut fields: Vec<(&str, String)> = Vec::new();
251        if let Some(extras) = extra_parameters {
252            for (k, v) in extras {
253                fields.push((k.as_str(), v.clone()));
254            }
255        }
256        fields.push(("url", url.to_string()));
257        fields.push(("radio_id", radio_id.to_string()));
258        if let Some(cb) = callbacks {
259            fields.push(("callbacks", cb.to_string()));
260        }
261        post_form(
262            self.inner,
263            "addStream",
264            &format!("{}/addStream/", self.inner.api_base),
265            &fields,
266            self.inner.mutating_policy(),
267        )
268        .await
269        .map(drop)
270    }
271
272    /// Update the URL of an existing stream subscription.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`AudDError`] for transport/server failures.
277    pub async fn set_url(&self, radio_id: i64, url: &str) -> Result<(), AudDError> {
278        post_form(
279            self.inner,
280            "setStreamUrl",
281            &format!("{}/setStreamUrl/", self.inner.api_base),
282            &[("radio_id", radio_id.to_string()), ("url", url.to_string())],
283            self.inner.mutating_policy(),
284        )
285        .await
286        .map(drop)
287    }
288
289    /// Delete a stream subscription.
290    ///
291    /// # Errors
292    ///
293    /// Returns [`AudDError`] for transport/server failures.
294    pub async fn delete(&self, radio_id: i64) -> Result<(), AudDError> {
295        post_form(
296            self.inner,
297            "deleteStream",
298            &format!("{}/deleteStream/", self.inner.api_base),
299            &[("radio_id", radio_id.to_string())],
300            self.inner.mutating_policy(),
301        )
302        .await
303        .map(drop)
304    }
305
306    /// List all stream subscriptions on the caller's account.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`AudDError`] for transport/server/parse failures.
311    pub async fn list(&self) -> Result<Vec<StreamRow>, AudDError> {
312        let result = post_form(
313            self.inner,
314            "getStreams",
315            &format!("{}/getStreams/", self.inner.api_base),
316            &[],
317            self.inner.read_policy(),
318        )
319        .await?;
320        if result.is_null() {
321            return Ok(Vec::new());
322        }
323        let v: Vec<StreamRow> =
324            serde_json::from_value(result.clone()).map_err(|e| AudDError::Serialization {
325                message: format!("could not parse getStreams result: {e}"),
326                raw_text: result.to_string(),
327            })?;
328        Ok(v)
329    }
330
331    /// Compute the 9-char longpoll category locally from `(api_token, radio_id)`.
332    /// Pure function — no network call. Snapshots the live api_token, so it
333    /// reflects any prior `AudD::set_api_token` rotation.
334    #[must_use]
335    pub fn derive_longpoll_category(&self, radio_id: i64) -> String {
336        derive_longpoll_category(&self.inner.api_token(), radio_id)
337    }
338
339    /// Parse an already-deserialized callback POST body into a typed
340    /// [`CallbackEvent`].
341    ///
342    /// # Errors
343    ///
344    /// Returns [`AudDError::Serialization`] if the body doesn't deserialize.
345    pub fn parse_callback(&self, body: Value) -> Result<CallbackEvent, AudDError> {
346        parse_callback(body)
347    }
348
349    /// Long-poll the AudD streams endpoint and return a [`LongpollPoll`]
350    /// handle whose typed streams (matches / notifications / errors) are
351    /// fed by a background tokio task.
352    ///
353    /// Server keepalive ticks (`{"timeout": "no events before timeout"}`) are
354    /// silently absorbed — they advance the internal cursor and never reach
355    /// the consumer.
356    ///
357    /// On entry, performs a one-time `getCallbackUrl` preflight unless
358    /// `opts.skip_callback_check == true`. If the server's response indicates
359    /// that no callback URL is configured, [`AudDError::Api`] is returned with
360    /// kind [`ErrorKind::InvalidRequest`] explaining how to fix it; any other
361    /// server error passes through unchanged.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`AudDError`] from the preflight only. Fatal errors during
366    /// polling surface on [`LongpollPoll::errors`].
367    pub async fn longpoll(
368        &self,
369        category: &str,
370        opts: LongpollOptions,
371    ) -> Result<LongpollPoll, AudDError> {
372        if !opts.skip_callback_check {
373            self.preflight_callback().await?;
374        }
375        Ok(spawn_longpoll(LongpollDriver::Authenticated {
376            http: self.inner.http.clone(),
377            url: format!("{}/longpoll/", self.inner.api_base),
378            policy: self.inner.read_policy(),
379            category: category.to_string(),
380            opts,
381        }))
382    }
383
384    /// One-step longpoll for the common `(api_token, radio_id)` case: derives
385    /// the 9-char category internally and delegates to [`Self::longpoll`].
386    ///
387    /// Use [`Self::longpoll`] directly for the tokenless / share-the-category
388    /// flow (server derives, ships only the 9-char string to a browser /
389    /// mobile / embedded client).
390    ///
391    /// # Errors
392    ///
393    /// Same as [`Self::longpoll`] — propagates any preflight error.
394    pub async fn longpoll_by_radio_id(
395        &self,
396        radio_id: i64,
397        opts: LongpollOptions,
398    ) -> Result<LongpollPoll, AudDError> {
399        let category = self.derive_longpoll_category(radio_id);
400        self.longpoll(&category, opts).await
401    }
402
403    async fn preflight_callback(&self) -> Result<(), AudDError> {
404        match self.get_callback_url().await {
405            Ok(_) => Ok(()),
406            Err(AudDError::Api {
407                code: NO_CALLBACK_ERROR_CODE,
408                message,
409                http_status,
410                request_id,
411                ..
412            }) if indicates_no_callback_url(&message) => Err(AudDError::Api {
413                code: 0,
414                message: PREFLIGHT_NO_CALLBACK_HINT.to_string(),
415                kind: ErrorKind::InvalidRequest,
416                http_status,
417                request_id,
418                requested_params: std::collections::HashMap::new(),
419                request_method: None,
420                branded_message: None,
421                raw_response: Value::Null,
422            }),
423            // Any other error — including a #19 whose message indicates a real
424            // server condition (maintenance, blocked, ...) — passes through.
425            Err(other) => Err(other),
426        }
427    }
428}
429
430/// Internal — describes a single longpoll fetch source so authenticated and
431/// tokenless consumers share the same dispatch loop.
432pub(crate) enum LongpollDriver {
433    Authenticated {
434        http: HttpClient,
435        url: String,
436        policy: RetryPolicy,
437        category: String,
438        opts: LongpollOptions,
439    },
440    Tokenless {
441        http: crate::http::BareHttpClient,
442        url: String,
443        policy: RetryPolicy,
444        category: String,
445        since_time: Option<i64>,
446        timeout: i64,
447    },
448}
449
450impl LongpollDriver {
451    fn category(&self) -> &str {
452        match self {
453            Self::Authenticated { category, .. } | Self::Tokenless { category, .. } => category,
454        }
455    }
456
457    fn timeout(&self) -> i64 {
458        match self {
459            Self::Authenticated { opts, .. } => opts.timeout,
460            Self::Tokenless { timeout, .. } => *timeout,
461        }
462    }
463
464    fn since_time(&self) -> Option<i64> {
465        match self {
466            Self::Authenticated { opts, .. } => opts.since_time,
467            Self::Tokenless { since_time, .. } => *since_time,
468        }
469    }
470
471    async fn fetch(
472        &self,
473        params: &[(&str, String)],
474    ) -> Result<crate::http::HttpResponse, AudDError> {
475        // Size each longpoll request's HTTP deadline to the poll timeout plus
476        // a margin, so poll timeouts above the transport's default budget
477        // aren't cut short mid-poll.
478        let request_timeout = longpoll_request_timeout(self.timeout());
479        match self {
480            Self::Authenticated {
481                http, url, policy, ..
482            } => {
483                let url = url.clone();
484                let policy = *policy;
485                let http = http.clone();
486                let params: Vec<(&str, String)> =
487                    params.iter().map(|(k, v)| (*k, v.clone())).collect();
488                retry_async(
489                    || {
490                        let http = http.clone();
491                        let url = url.clone();
492                        let params = params.clone();
493                        async move { http.get(&url, &params, Some(request_timeout)).await }
494                    },
495                    policy,
496                )
497                .await
498            }
499            Self::Tokenless {
500                http, url, policy, ..
501            } => {
502                let url = url.clone();
503                let policy = *policy;
504                let http = http.clone();
505                let params: Vec<(&str, String)> =
506                    params.iter().map(|(k, v)| (*k, v.clone())).collect();
507                retry_async(
508                    || {
509                        let http = http.clone();
510                        let url = url.clone();
511                        let params = params.clone();
512                        async move { http.get(&url, &params, Some(request_timeout)).await }
513                    },
514                    policy,
515                )
516                .await
517            }
518        }
519    }
520}
521
522/// Spawn the background poll task and wire up the three streams.
523pub(crate) fn spawn_longpoll(driver: LongpollDriver) -> LongpollPoll {
524    let (match_tx, match_rx) = mpsc::channel::<StreamCallbackMatch>(CHANNEL_BUFFER);
525    let (notif_tx, notif_rx) = mpsc::channel::<StreamCallbackNotification>(CHANNEL_BUFFER);
526    let (err_tx, err_rx) = mpsc::channel::<AudDError>(1);
527    let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(1);
528
529    let join = tokio::spawn(run_longpoll(
530        driver,
531        match_tx,
532        notif_tx,
533        err_tx,
534        shutdown_rx,
535    ));
536
537    LongpollPoll {
538        matches: Box::pin(channel_stream(match_rx)),
539        notifications: Box::pin(channel_stream(notif_rx)),
540        errors: Box::pin(channel_stream(err_rx)),
541        shutdown: Some(shutdown_tx),
542        join: Some(join),
543    }
544}
545
546fn channel_stream<T: Send + 'static>(mut rx: mpsc::Receiver<T>) -> impl Stream<Item = T> + Send {
547    async_stream::stream! {
548        while let Some(item) = rx.recv().await {
549            yield item;
550        }
551    }
552}
553
554/// Drive a single longpoll subscription: read responses, parse them, and
555/// dispatch to the typed channels. Exits when the shutdown signal is dropped,
556/// a fatal error fires, or all channels are closed.
557async fn run_longpoll(
558    driver: LongpollDriver,
559    match_tx: mpsc::Sender<StreamCallbackMatch>,
560    notif_tx: mpsc::Sender<StreamCallbackNotification>,
561    err_tx: mpsc::Sender<AudDError>,
562    mut shutdown_rx: mpsc::Receiver<()>,
563) {
564    let mut cur_since = driver.since_time();
565    let timeout_secs = driver.timeout().to_string();
566    let category = driver.category().to_string();
567
568    loop {
569        // Build params each iteration so since_time updates flow through.
570        let mut params: Vec<(&str, String)> = vec![
571            ("category", category.clone()),
572            ("timeout", timeout_secs.clone()),
573        ];
574        if let Some(t) = cur_since {
575            params.push(("since_time", t.to_string()));
576        }
577
578        // Race the fetch against shutdown so a quiescent caller can hang up
579        // even mid-poll.
580        let resp = tokio::select! {
581            biased;
582            _ = shutdown_rx.recv() => return,
583            r = driver.fetch(&params) => r,
584        };
585
586        let resp = match resp {
587            Ok(r) => r,
588            Err(e) => {
589                let _ = err_tx.send(e).await;
590                return;
591            }
592        };
593
594        // Surface non-2xx as a Server error.
595        if resp.http_status >= HTTP_CLIENT_ERROR_FLOOR {
596            let _ = err_tx
597                .send(AudDError::Server {
598                    http_status: resp.http_status,
599                    message: format!("Longpoll endpoint returned HTTP {}", resp.http_status),
600                    request_id: resp.request_id,
601                    raw_response: resp.raw_text,
602                })
603                .await;
604            return;
605        }
606
607        let Some(body) = resp.json_body else {
608            let _ = err_tx
609                .send(AudDError::Serialization {
610                    message: "Longpoll response was not a JSON object".into(),
611                    raw_text: resp.raw_text,
612                })
613                .await;
614            return;
615        };
616
617        // Silently absorb keepalive ticks.
618        if is_longpoll_keepalive(&body) {
619            if let Some(ts) = body.get("timestamp").and_then(Value::as_i64) {
620                cur_since = Some(ts);
621            }
622            continue;
623        }
624
625        // Advance the cursor before parsing — even if parsing fails, we don't
626        // want to re-poll the same window.
627        if let Some(ts) = body.get("timestamp").and_then(Value::as_i64) {
628            cur_since = Some(ts);
629        }
630
631        match parse_callback(body) {
632            Ok(CallbackEvent::Match(m)) => {
633                tokio::select! {
634                    biased;
635                    _ = shutdown_rx.recv() => return,
636                    res = match_tx.send(m) => {
637                        if res.is_err() { return; }
638                    }
639                }
640            }
641            Ok(CallbackEvent::Notification(n)) => {
642                tokio::select! {
643                    biased;
644                    _ = shutdown_rx.recv() => return,
645                    res = notif_tx.send(n) => {
646                        if res.is_err() { return; }
647                    }
648                }
649            }
650            Err(e) => {
651                let _ = err_tx.send(e).await;
652                return;
653            }
654        }
655    }
656}
657
658/// Reports whether `body` is a `{"timeout": "no events before timeout"}`
659/// keepalive tick — the server emits one of these every `<timeout>` seconds
660/// when no recognition or notification is queued. Mirrors audd-go's
661/// `isLongpollKeepalive` helper.
662pub(crate) fn is_longpoll_keepalive(body: &Value) -> bool {
663    let Some(obj) = body.as_object() else {
664        return false;
665    };
666    if obj.contains_key("result") || obj.contains_key("notification") {
667        return false;
668    }
669    obj.contains_key("timeout")
670}
671
672/// Internal — POST a form body to a streams-namespace endpoint and return the
673/// `result` field on success. Emits request/response/exception lifecycle
674/// events for the registered `on_event` hook.
675async fn post_form(
676    inner: &AudDInner,
677    method: &str,
678    url: &str,
679    fields: &[(&str, String)],
680    policy: RetryPolicy,
681) -> Result<Value, AudDError> {
682    let http = &inner.http;
683    let url = url.to_string();
684    let fields: Vec<(&str, String)> = fields.iter().map(|(k, v)| (*k, v.clone())).collect();
685    let started = inner.emit_request(method, &url);
686    let resp = retry_async(
687        || {
688            let http = http.clone();
689            let url = url.clone();
690            let fields = fields.clone();
691            async move { http.post_form(&url, &fields, None, None).await }
692        },
693        policy,
694    )
695    .await;
696    let resp = match resp {
697        Ok(r) => r,
698        Err(e) => {
699            inner.emit_exception(method, &url, started, &e);
700            return Err(e);
701        }
702    };
703    inner.emit_response(method, &url, started, &resp);
704    let body = decode_or_raise(resp, false)?;
705    Ok(body.get("result").cloned().unwrap_or(Value::Null))
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn longpoll_options_default() {
714        let o = LongpollOptions::default();
715        assert_eq!(o.timeout, 50);
716        assert!(!o.skip_callback_check);
717    }
718
719    #[test]
720    fn longpoll_options_chain() {
721        let o = LongpollOptions::default()
722            .timeout(30)
723            .since_time(123)
724            .skip_callback_check(true);
725        assert_eq!(o.timeout, 30);
726        assert_eq!(o.since_time, Some(123));
727        assert!(o.skip_callback_check);
728    }
729
730    #[test]
731    fn longpoll_request_timeout_sizes_above_poll_timeout() {
732        assert_eq!(
733            longpoll_request_timeout(50),
734            std::time::Duration::from_secs(60)
735        );
736        assert_eq!(
737            longpoll_request_timeout(300),
738            std::time::Duration::from_secs(310)
739        );
740        // Negative (invalid) timeouts degrade to just the margin.
741        assert_eq!(
742            longpoll_request_timeout(-1),
743            std::time::Duration::from_secs(10)
744        );
745    }
746
747    #[test]
748    fn no_callback_url_signal_detection() {
749        assert!(indicates_no_callback_url("Internal error"));
750        assert!(indicates_no_callback_url("no callback url set"));
751        assert!(!indicates_no_callback_url(
752            "Scheduled maintenance, try again later"
753        ));
754        assert!(!indicates_no_callback_url("request blocked"));
755    }
756
757    #[test]
758    fn keepalive_detection() {
759        let kp = serde_json::json!({"timeout": "no events before timeout", "timestamp": 1});
760        assert!(is_longpoll_keepalive(&kp));
761
762        let with_result = serde_json::json!({
763            "result": {"radio_id": 1, "results": []},
764            "timeout": "no events"
765        });
766        assert!(!is_longpoll_keepalive(&with_result));
767
768        let with_notif = serde_json::json!({
769            "notification": {"radio_id": 1},
770            "timeout": "x"
771        });
772        assert!(!is_longpoll_keepalive(&with_notif));
773
774        let no_timeout = serde_json::json!({"timestamp": 1});
775        assert!(!is_longpoll_keepalive(&no_timeout));
776
777        let not_object = serde_json::json!([1, 2, 3]);
778        assert!(!is_longpoll_keepalive(&not_object));
779    }
780}