286 lines
8.2 KiB
Rust
286 lines
8.2 KiB
Rust
|
|
use std::sync::Arc;
|
||
|
|
|
||
|
|
use async_trait::async_trait;
|
||
|
|
use vapora_channels::{
|
||
|
|
config::{DiscordConfig, SlackConfig, TelegramConfig},
|
||
|
|
discord::DiscordChannel,
|
||
|
|
error::ChannelError,
|
||
|
|
message::Message,
|
||
|
|
registry::ChannelRegistry,
|
||
|
|
slack::SlackChannel,
|
||
|
|
telegram::TelegramChannel,
|
||
|
|
NotificationChannel, Result,
|
||
|
|
};
|
||
|
|
use wiremock::{
|
||
|
|
matchers::{method, path},
|
||
|
|
Mock, MockServer, ResponseTemplate,
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Slack ─────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn slack_send_returns_ok_on_200() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/hooks/slack"))
|
||
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = SlackConfig {
|
||
|
|
webhook_url: format!("{}/hooks/slack", server.uri()),
|
||
|
|
channel: None,
|
||
|
|
username: None,
|
||
|
|
};
|
||
|
|
let channel = SlackChannel::new("slack", cfg, reqwest::Client::new());
|
||
|
|
let msg = Message::success("Deploy complete", "v1.2.0 → production");
|
||
|
|
|
||
|
|
channel.send(&msg).await.expect("should succeed on 200");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn slack_send_returns_api_error_on_500() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/hooks/slack"))
|
||
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("internal_error"))
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = SlackConfig {
|
||
|
|
webhook_url: format!("{}/hooks/slack", server.uri()),
|
||
|
|
channel: None,
|
||
|
|
username: None,
|
||
|
|
};
|
||
|
|
let channel = SlackChannel::new("slack", cfg, reqwest::Client::new());
|
||
|
|
let err = channel
|
||
|
|
.send(&Message::info("Test", "Body"))
|
||
|
|
.await
|
||
|
|
.unwrap_err();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
matches!(
|
||
|
|
err,
|
||
|
|
ChannelError::ApiError {
|
||
|
|
status: 500,
|
||
|
|
ref body,
|
||
|
|
..
|
||
|
|
} if body == "internal_error"
|
||
|
|
),
|
||
|
|
"unexpected error variant: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Discord
|
||
|
|
// ───────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn discord_send_returns_ok_on_204() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/webhooks/discord"))
|
||
|
|
.respond_with(ResponseTemplate::new(204))
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = DiscordConfig {
|
||
|
|
webhook_url: format!("{}/webhooks/discord", server.uri()),
|
||
|
|
username: None,
|
||
|
|
avatar_url: None,
|
||
|
|
};
|
||
|
|
let channel = DiscordChannel::new("discord", cfg, reqwest::Client::new());
|
||
|
|
|
||
|
|
channel
|
||
|
|
.send(&Message::warning("High latency", "p99 > 500 ms"))
|
||
|
|
.await
|
||
|
|
.expect("should succeed on 204");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn discord_send_returns_api_error_on_400() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/webhooks/discord"))
|
||
|
|
.respond_with(ResponseTemplate::new(400).set_body_string("{\"code\":50006}"))
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = DiscordConfig {
|
||
|
|
webhook_url: format!("{}/webhooks/discord", server.uri()),
|
||
|
|
username: None,
|
||
|
|
avatar_url: None,
|
||
|
|
};
|
||
|
|
let channel = DiscordChannel::new("discord", cfg, reqwest::Client::new());
|
||
|
|
let err = channel
|
||
|
|
.send(&Message::info("Test", "Body"))
|
||
|
|
.await
|
||
|
|
.unwrap_err();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
matches!(err, ChannelError::ApiError { status: 400, .. }),
|
||
|
|
"unexpected error variant: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Telegram
|
||
|
|
// ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn telegram_send_returns_ok_on_200() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
// Telegram returns {"ok": true, "result": {...}} with HTTP 200.
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/botTEST_TOKEN/sendMessage"))
|
||
|
|
.respond_with(
|
||
|
|
ResponseTemplate::new(200).set_body_string(r#"{"ok":true,"result":{"message_id":1}}"#),
|
||
|
|
)
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = TelegramConfig {
|
||
|
|
bot_token: "TEST_TOKEN".to_string(),
|
||
|
|
chat_id: "-100999".to_string(),
|
||
|
|
api_base: Some(server.uri()),
|
||
|
|
};
|
||
|
|
let channel = TelegramChannel::new("telegram", cfg, reqwest::Client::new());
|
||
|
|
|
||
|
|
channel
|
||
|
|
.send(&Message::error("Service down", "Critical alert"))
|
||
|
|
.await
|
||
|
|
.expect("should succeed on 200");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn telegram_send_returns_api_error_on_400() {
|
||
|
|
let server = MockServer::start().await;
|
||
|
|
|
||
|
|
Mock::given(method("POST"))
|
||
|
|
.and(path("/botBAD_TOKEN/sendMessage"))
|
||
|
|
.respond_with(
|
||
|
|
ResponseTemplate::new(400)
|
||
|
|
.set_body_string(r#"{"ok":false,"description":"Unauthorized"}"#),
|
||
|
|
)
|
||
|
|
.mount(&server)
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let cfg = TelegramConfig {
|
||
|
|
bot_token: "BAD_TOKEN".to_string(),
|
||
|
|
chat_id: "-100".to_string(),
|
||
|
|
api_base: Some(server.uri()),
|
||
|
|
};
|
||
|
|
let channel = TelegramChannel::new("telegram", cfg, reqwest::Client::new());
|
||
|
|
let err = channel
|
||
|
|
.send(&Message::info("Test", "Body"))
|
||
|
|
.await
|
||
|
|
.unwrap_err();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
matches!(err, ChannelError::ApiError { status: 400, .. }),
|
||
|
|
"unexpected error variant: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Registry
|
||
|
|
// ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
struct AlwaysOkChannel {
|
||
|
|
name: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl NotificationChannel for AlwaysOkChannel {
|
||
|
|
fn name(&self) -> &str {
|
||
|
|
&self.name
|
||
|
|
}
|
||
|
|
async fn send(&self, _msg: &Message) -> Result<()> {
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
struct AlwaysFailChannel {
|
||
|
|
name: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl NotificationChannel for AlwaysFailChannel {
|
||
|
|
fn name(&self) -> &str {
|
||
|
|
&self.name
|
||
|
|
}
|
||
|
|
async fn send(&self, _msg: &Message) -> Result<()> {
|
||
|
|
Err(ChannelError::ApiError {
|
||
|
|
channel: self.name.clone(),
|
||
|
|
status: 503,
|
||
|
|
body: "unavailable".to_string(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn registry_send_routes_to_named_channel() {
|
||
|
|
let mut registry = ChannelRegistry::new();
|
||
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
||
|
|
name: "ok-channel".to_string(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
registry
|
||
|
|
.send("ok-channel", Message::info("Test", "Body"))
|
||
|
|
.await
|
||
|
|
.expect("should route to ok-channel and succeed");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn registry_send_returns_not_found_for_unknown_channel() {
|
||
|
|
let registry = ChannelRegistry::new();
|
||
|
|
let err = registry
|
||
|
|
.send("does-not-exist", Message::info("Test", "Body"))
|
||
|
|
.await
|
||
|
|
.unwrap_err();
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
matches!(err, ChannelError::NotFound(ref n) if n == "does-not-exist"),
|
||
|
|
"unexpected error: {err}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn registry_broadcast_delivers_to_all_channels() {
|
||
|
|
let mut registry = ChannelRegistry::new();
|
||
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
||
|
|
name: "ch-a".to_string(),
|
||
|
|
}));
|
||
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
||
|
|
name: "ch-b".to_string(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
let results = registry
|
||
|
|
.broadcast(Message::success("All systems green", ""))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
assert_eq!(results.len(), 2);
|
||
|
|
assert!(results.iter().all(|(_, r)| r.is_ok()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn registry_broadcast_continues_after_partial_failure() {
|
||
|
|
let mut registry = ChannelRegistry::new();
|
||
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
||
|
|
name: "good".to_string(),
|
||
|
|
}));
|
||
|
|
registry.register(Arc::new(AlwaysFailChannel {
|
||
|
|
name: "bad".to_string(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
let results = registry.broadcast(Message::info("Test", "Body")).await;
|
||
|
|
|
||
|
|
assert_eq!(results.len(), 2);
|
||
|
|
let ok_count = results.iter().filter(|(_, r)| r.is_ok()).count();
|
||
|
|
let err_count = results.iter().filter(|(_, r)| r.is_err()).count();
|
||
|
|
assert_eq!(ok_count, 1);
|
||
|
|
assert_eq!(err_count, 1);
|
||
|
|
}
|