feat: ntfy notifications for imported, needs-decision, broken (#96)
This commit was merged in pull request #96.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! 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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user