106 lines
3.1 KiB
Rust
106 lines
3.1 KiB
Rust
//! ntfy delivery for the three DESIGN.md §9.5 events: imported (to a title's
|
|
//! owners), needs-a-decision and broken (both to the operator alone). See
|
|
//! `attention.rs` and `broken.rs` for what decides *when* to send.
|
|
|
|
use std::time::Duration;
|
|
|
|
use reqwest::Client;
|
|
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum NotifyError {
|
|
#[error("request: {0}")]
|
|
Request(#[from] reqwest::Error),
|
|
#[error("ntfy returned {0}")]
|
|
Status(reqwest::StatusCode),
|
|
}
|
|
|
|
/// A client for posting to one ntfy server, many topics.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Notifier {
|
|
client: Client,
|
|
base_url: String,
|
|
}
|
|
|
|
impl Notifier {
|
|
pub fn new(base_url: impl Into<String>) -> Result<Self, NotifyError> {
|
|
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
|
|
Ok(Self {
|
|
client,
|
|
base_url: base_url.into(),
|
|
})
|
|
}
|
|
|
|
/// Post a plain-text message to one topic. The title is the message's
|
|
/// first line rather than ntfy's `Title` header — that header must be
|
|
/// ASCII, and titles here are TMDB titles in whatever language they were
|
|
/// released.
|
|
pub async fn send(&self, topic: &str, title: &str, body: &str) -> Result<(), NotifyError> {
|
|
let url = format!("{}/{topic}", self.base_url.trim_end_matches('/'));
|
|
let response = self
|
|
.client
|
|
.post(url)
|
|
.body(format!("{title}\n{body}"))
|
|
.send()
|
|
.await?;
|
|
if !response.status().is_success() {
|
|
return Err(NotifyError::Status(response.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use wiremock::matchers::{body_string, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn send_posts_title_and_body_to_the_topic() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/owner-topic"))
|
|
.and(body_string("Dune: Part Two (2024)\nimported"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let notifier = Notifier::new(server.uri()).unwrap();
|
|
|
|
notifier
|
|
.send("owner-topic", "Dune: Part Two (2024)", "imported")
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_non_success_status_is_an_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(500))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let notifier = Notifier::new(server.uri()).unwrap();
|
|
|
|
assert!(matches!(
|
|
notifier.send("topic", "title", "body").await,
|
|
Err(NotifyError::Status(_))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unreachable_server_is_an_error_the_caller_can_swallow() {
|
|
let notifier = Notifier::new("http://127.0.0.1:1").unwrap();
|
|
|
|
assert!(matches!(
|
|
notifier.send("topic", "title", "body").await,
|
|
Err(NotifyError::Request(_))
|
|
));
|
|
}
|
|
}
|