Skip to main content

apollo/
channel_check.rs

1//! `apollo channel-check` — drive one channel against its **real** service.
2//!
3//! `tests/channel_conformance.rs` proves a channel is correct against a mock
4//! built from the published API shapes. That closes "compiles" versus "works",
5//! and nothing more: a wrong field name, a deprecated endpoint or a missing
6//! scope all pass a mock happily and fail against the real service. Every
7//! channel except Telegram and CLI shipped verified only that far.
8//!
9//! This closes the rest of the gap, but it needs credentials and a human to
10//! echo a message, so it is a command rather than a test.
11
12use std::time::Duration;
13
14use crate::channels::{Channel, ChannelSettings, MediaKind, OutgoingMedia, OutgoingMessage};
15
16/// One step's outcome, printed as it happens so a hang is attributable.
17pub struct StepReport {
18    pub name: &'static str,
19    pub ok: bool,
20    pub detail: String,
21}
22
23impl StepReport {
24    fn pass(name: &'static str, detail: impl Into<String>) -> Self {
25        Self {
26            name,
27            ok: true,
28            detail: detail.into(),
29        }
30    }
31    fn fail(name: &'static str, detail: impl Into<String>) -> Self {
32        Self {
33            name,
34            ok: false,
35            detail: detail.into(),
36        }
37    }
38
39    fn print(&self) {
40        let mark = if self.ok { "ok" } else { "FAIL" };
41        println!("  [{mark}] {} — {}", self.name, self.detail);
42    }
43}
44
45/// What the check needs beyond the channel itself.
46pub struct CheckOptions {
47    pub chat_id: Option<String>,
48    pub wait: Duration,
49    pub media: bool,
50}
51
52/// Run the check. Returns the steps, all of which must pass for the channel to
53/// be considered verified against the real service.
54pub async fn run(
55    channel: &mut dyn Channel,
56    settings: &ChannelSettings,
57    opts: &CheckOptions,
58) -> Vec<StepReport> {
59    let mut steps = Vec::new();
60    let name = channel.name().to_string();
61
62    // A nonce makes the echo unambiguous: a channel that replays history or
63    // loops its own messages back would otherwise look like a pass.
64    let nonce = format!("apollo-check-{}", uuid::Uuid::new_v4().simple());
65
66    let rx = match channel.start().await {
67        Ok(rx) => {
68            steps.push(StepReport::pass("start", "receiver opened"));
69            Some(rx)
70        }
71        Err(e) => {
72            steps.push(StepReport::fail("start", e.to_string()));
73            None
74        }
75    };
76    steps.last().unwrap().print();
77
78    let chat_id = opts
79        .chat_id
80        .clone()
81        .or_else(|| settings.get("chat_id"))
82        .or_else(|| settings.get("channel_id"))
83        .unwrap_or_default();
84
85    // send
86    let sent = channel
87        .send(OutgoingMessage {
88            chat_id: chat_id.clone(),
89            text: format!("{name} check — please reply with: {nonce}"),
90            reply_to: None,
91        })
92        .await;
93    steps.push(match &sent {
94        Ok(id) => StepReport::pass(
95            "send",
96            match id {
97                Some(id) => format!("delivered, id {id}"),
98                None => "delivered".to_string(),
99            },
100        ),
101        Err(e) => StepReport::fail("send", e.to_string()),
102    });
103    steps.last().unwrap().print();
104
105    // media, only if the channel claims to support it
106    if opts.media {
107        if channel.supports_media() {
108            let path = std::env::temp_dir().join("apollo-channel-check.txt");
109            let step = match tokio::fs::write(&path, b"apollo channel-check attachment\n").await {
110                Err(e) => StepReport::fail("media", format!("could not write temp file: {e}")),
111                Ok(()) => {
112                    let media = OutgoingMedia::file(&chat_id, MediaKind::Document, &path)
113                        .with_caption("apollo channel-check attachment");
114                    match channel.send_media(media).await {
115                        Ok(_) => StepReport::pass("media", "attachment uploaded"),
116                        Err(e) => StepReport::fail("media", e.to_string()),
117                    }
118                }
119            };
120            let _ = tokio::fs::remove_file(&path).await;
121            steps.push(step);
122        } else {
123            steps.push(StepReport::pass(
124                "media",
125                "channel reports no media support — skipped",
126            ));
127        }
128        steps.last().unwrap().print();
129    }
130
131    // receive — the half a mock can never really prove
132    if let Some(mut rx) = rx {
133        println!(
134            "  .... waiting {}s for you to reply with the nonce",
135            opts.wait.as_secs()
136        );
137        let deadline = tokio::time::Instant::now() + opts.wait;
138        let step = loop {
139            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
140            if remaining.is_zero() {
141                break StepReport::fail(
142                    "receive",
143                    format!("no message containing {nonce} within the timeout"),
144                );
145            }
146            match tokio::time::timeout(remaining, rx.recv()).await {
147                Ok(Some(msg)) if msg.text.contains(&nonce) => {
148                    break StepReport::pass(
149                        "receive",
150                        format!("echo from {} in chat {}", msg.sender_id, msg.chat_id),
151                    );
152                }
153                // Other traffic is fine; keep waiting for ours.
154                Ok(Some(_)) => continue,
155                Ok(None) => {
156                    break StepReport::fail("receive", "receiver closed before the echo arrived")
157                }
158                Err(_) => {
159                    break StepReport::fail(
160                        "receive",
161                        format!("no message containing {nonce} within the timeout"),
162                    )
163                }
164            }
165        };
166        steps.push(step);
167        steps.last().unwrap().print();
168    }
169
170    let _ = channel.stop().await;
171    steps
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::channels::IncomingMessage;
178    use async_trait::async_trait;
179    use tokio::sync::mpsc;
180
181    /// A channel that echoes whatever it is sent, which is what a cooperative
182    /// human does during a real check.
183    struct EchoChannel {
184        tx: Option<mpsc::Sender<IncomingMessage>>,
185    }
186
187    #[async_trait]
188    impl Channel for EchoChannel {
189        fn name(&self) -> &str {
190            "echo"
191        }
192        async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
193            let (tx, rx) = mpsc::channel(4);
194            self.tx = Some(tx);
195            Ok(rx)
196        }
197        async fn send(&self, message: OutgoingMessage) -> anyhow::Result<Option<String>> {
198            if let Some(tx) = &self.tx {
199                let _ = tx
200                    .send(IncomingMessage {
201                        id: "1".into(),
202                        sender_id: "u".into(),
203                        sender_name: None,
204                        chat_id: message.chat_id.clone(),
205                        text: message.text.clone(),
206                        is_group: false,
207                        reply_to: None,
208                        timestamp: chrono::Utc::now(),
209                    })
210                    .await;
211            }
212            Ok(Some("m1".into()))
213        }
214        async fn stop(&mut self) -> anyhow::Result<()> {
215            Ok(())
216        }
217    }
218
219    #[tokio::test]
220    async fn a_working_channel_passes_every_step() {
221        let mut channel = EchoChannel { tx: None };
222        let steps = run(
223            &mut channel,
224            &ChannelSettings::default(),
225            &CheckOptions {
226                chat_id: Some("c1".into()),
227                wait: Duration::from_secs(5),
228                media: false,
229            },
230        )
231        .await;
232
233        assert!(
234            steps.iter().all(|s| s.ok),
235            "{:?}",
236            steps
237                .iter()
238                .map(|s| (s.name, s.ok, &s.detail))
239                .collect::<Vec<_>>()
240        );
241        assert!(steps.iter().any(|s| s.name == "receive"));
242    }
243
244    /// A channel that sends nothing back must fail the receive step rather
245    /// than reporting a clean run — that is the whole point of the command.
246    struct SilentChannel;
247
248    #[async_trait]
249    impl Channel for SilentChannel {
250        fn name(&self) -> &str {
251            "silent"
252        }
253        async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
254            let (_tx, rx) = mpsc::channel(1);
255            Ok(rx)
256        }
257        async fn send(&self, _message: OutgoingMessage) -> anyhow::Result<Option<String>> {
258            Ok(None)
259        }
260        async fn stop(&mut self) -> anyhow::Result<()> {
261            Ok(())
262        }
263    }
264
265    #[tokio::test]
266    async fn a_deaf_channel_fails_the_receive_step() {
267        let mut channel = SilentChannel;
268        let steps = run(
269            &mut channel,
270            &ChannelSettings::default(),
271            &CheckOptions {
272                chat_id: Some("c1".into()),
273                wait: Duration::from_millis(200),
274                media: false,
275            },
276        )
277        .await;
278
279        let receive = steps.iter().find(|s| s.name == "receive").unwrap();
280        assert!(!receive.ok, "a deaf channel must not pass");
281        assert!(steps.iter().find(|s| s.name == "send").unwrap().ok);
282    }
283}