Skip to content

Commit 88f151f

Browse files
committed
test: add unit tests for notification debouncing
1 parent 9ab9983 commit 88f151f

File tree

3 files changed

+169
-13
lines changed

3 files changed

+169
-13
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ serde = { version = "1.0.219", features = ["derive"] }
3939
serde_json = "1.0.141"
4040
tera = { version = "1.20.0", default-features = false, features = [ "urlencode" ] }
4141
thiserror = { version = "2.0.12", default-features = false }
42-
tokio = { version = "1.47.0", features = ["macros", "rt-multi-thread", "test-util"] }
42+
tokio = { version = "1.47.0", features = ["macros", "rt-multi-thread"] }
4343
toml = { version = "0.9.2", default-features = false, features = ["preserve_order", "parse", "serde", "std"] }
4444
tower-http = { version = "0.6.6", features = ["cors", "fs", "catch-panic", "trace"] }
4545
tracing = "0.1.41"
@@ -52,3 +52,4 @@ httpmock = { version = "0.7.0", default-features = false }
5252
pretty_assertions = "1.4.1"
5353
tempfile = "3.20.0"
5454
tower = "0.5.2"
55+
tokio = { version = "1.47.0", features = ["macros", "rt-multi-thread", "test-util"] }

src/discord.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ use serde::{Deserialize, Serialize};
1515
use serde_json::json;
1616
use tracing::{error, instrument, warn};
1717

18+
/// The period within which at most one notification can be sent to each member about their site.
19+
pub const NOTIFICATION_DEBOUNCE_PERIOD: Duration = Duration::from_secs(60 * 60 * 24); // 24 hours
20+
1821
/// A user agent representing our program. [Required by Discord][api-doc-ua].
1922
///
2023
/// [api-doc-ua]: https://discord.com/developers/docs/reference#user-agent

src/webring.rs

Lines changed: 164 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ use std::{
1010
};
1111

1212
use axum::http::{Uri, uri::Authority};
13-
use chrono::{DateTime, TimeDelta, Utc};
13+
use chrono::TimeDelta;
1414
use futures::{StreamExt, future::join, stream::FuturesUnordered};
1515
use indexmap::IndexMap;
1616
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
1717
use rand::seq::SliceRandom;
18-
use tokio::sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock};
18+
use tokio::{
19+
sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock},
20+
task::JoinHandle,
21+
time::Instant,
22+
};
1923

2024
use sarlacc::Intern;
2125
use serde::Deserialize;
@@ -25,7 +29,7 @@ use tracing::{Instrument, debug, error, field::display, info, info_span, instrum
2529
use crate::{
2630
checking::check,
2731
config::{Config, MemberSpec},
28-
discord::{DiscordNotifier, Snowflake},
32+
discord::{DiscordNotifier, NOTIFICATION_DEBOUNCE_PERIOD, Snowflake},
2933
homepage::{Homepage, MemberForHomepage},
3034
stats::{Stats, UNKNOWN_ORIGIN},
3135
};
@@ -68,7 +72,7 @@ struct Member {
6872
/// Whether the last check was successful
6973
check_successful: Arc<AtomicBool>,
7074
/// The last time the member was notified of a failure.
71-
last_notified: Arc<AsyncMutex<Option<DateTime<Utc>>>>,
75+
last_notified: Arc<AsyncMutex<Option<Instant>>>,
7276
}
7377

7478
impl From<(&str, &MemberSpec)> for Member {
@@ -88,12 +92,15 @@ impl From<(&str, &MemberSpec)> for Member {
8892
impl Member {
8993
/// Checks the member's site, and stores the result. If the check fails and the member has
9094
/// opted in to notifications, also notifies them of the failure.
95+
///
96+
/// Returns a future that resolves to the handle of the spawned task that sends the Discord
97+
/// notification, if any. (Mainly intended for testing purposes.)
9198
#[instrument(name = "webring.check_member", skip_all, fields(site))]
9299
fn check_and_store_and_optionally_notify(
93100
&self,
94101
base_address: Intern<Uri>,
95102
notifier: Option<Arc<DiscordNotifier>>,
96-
) -> impl Future<Output = ()> + Send + 'static {
103+
) -> impl Future<Output = Option<JoinHandle<()>>> + Send + 'static {
97104
tracing::Span::current().record("site", display(&self.website));
98105

99106
let website = self.website;
@@ -106,29 +113,35 @@ impl Member {
106113
async move {
107114
let Ok(check_result) = check(&website, check_level, base_address_for_block).await
108115
else {
109-
return;
116+
return None;
110117
};
111118

119+
debug!(site = %website, ?check_result, "got check result for member site");
112120
if let Some(failure) = check_result {
113121
let prev_was_successful = successful.swap(false, Ordering::Relaxed);
114122
if let (Some(notifier), Some(user_id)) = (notifier, discord_id_for_block) {
115123
// Notifications are enabled. Send notification asynchronously.
116-
tokio::spawn(async move {
124+
Some(tokio::spawn(async move {
117125
// If the last check was successful or the last notification was sent more than
118126
// a day ago, notify the user.
119127
let mut last_notified = last_notified_for_block.lock().await;
120-
let now = Utc::now();
121128
if prev_was_successful
122-
|| last_notified.is_none_or(|last| (now - last) > TimeDelta::days(1))
129+
|| last_notified.is_none_or(|last| {
130+
Instant::now().duration_since(last) > NOTIFICATION_DEBOUNCE_PERIOD
131+
})
123132
{
124133
let message = format!("<@{}> {}", user_id, failure.to_message());
125-
let _ = notifier.send_message(Some(user_id), &message).await;
126-
*last_notified = Some(now);
134+
if notifier.send_message(Some(user_id), &message).await.is_ok() {
135+
*last_notified = Some(Instant::now());
136+
}
127137
}
128-
});
138+
}))
139+
} else {
140+
None
129141
}
130142
} else {
131143
successful.store(true, Ordering::Relaxed);
144+
None
132145
}
133146
}
134147
}
@@ -646,6 +659,7 @@ mod tests {
646659
routing::get,
647660
};
648661
use chrono::Utc;
662+
use httpmock::prelude::*;
649663
use indexmap::IndexMap;
650664
use indoc::indoc;
651665
use pretty_assertions::assert_eq;
@@ -655,6 +669,7 @@ mod tests {
655669

656670
use crate::{
657671
config::{Config, MemberSpec},
672+
discord::{DiscordNotifier, NOTIFICATION_DEBOUNCE_PERIOD, Snowflake},
658673
stats::{TIMEZONE, UNKNOWN_ORIGIN},
659674
webring::{CheckLevel, Webring},
660675
};
@@ -1123,4 +1138,141 @@ mod tests {
11231138
assert_eq!(0, weak_ptr.strong_count());
11241139
assert_eq!(0, weak_ptr.weak_count());
11251140
}
1141+
1142+
/// Test notification sending logic.
1143+
#[allow(clippy::too_many_lines)]
1144+
#[tokio::test]
1145+
async fn test_notification_debounce() {
1146+
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1147+
enum SiteStatus {
1148+
Up,
1149+
Down,
1150+
}
1151+
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1152+
enum ShouldNotify {
1153+
Yes,
1154+
No,
1155+
}
1156+
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1157+
enum NotifyStatus {
1158+
Success,
1159+
Fail,
1160+
}
1161+
1162+
let sequence = &[
1163+
// site is up; no notification
1164+
(SiteStatus::Up, ShouldNotify::No, None, None),
1165+
// site is newly down; should notify
1166+
(
1167+
SiteStatus::Down,
1168+
ShouldNotify::Yes,
1169+
Some(NotifyStatus::Success),
1170+
None,
1171+
),
1172+
// site is still down; should not notify
1173+
(
1174+
SiteStatus::Down,
1175+
ShouldNotify::No,
1176+
None,
1177+
Some(NOTIFICATION_DEBOUNCE_PERIOD + Duration::from_secs(10)),
1178+
),
1179+
// waited 25 hours; should notify again
1180+
(
1181+
SiteStatus::Down,
1182+
ShouldNotify::Yes,
1183+
Some(NotifyStatus::Fail),
1184+
None,
1185+
),
1186+
// last notification failed, so didn't count; should notify again
1187+
(
1188+
SiteStatus::Down,
1189+
ShouldNotify::Yes,
1190+
Some(NotifyStatus::Success),
1191+
None,
1192+
),
1193+
// site is still down; should not notify
1194+
(SiteStatus::Down, ShouldNotify::No, None, None),
1195+
];
1196+
1197+
let server = MockServer::start();
1198+
1199+
let site_url: Uri = server.url("/site").parse().unwrap();
1200+
let member = Member {
1201+
name: "bad".to_owned(),
1202+
website: Intern::new(site_url.clone()),
1203+
authority: Intern::new(site_url.into_parts().authority.unwrap()),
1204+
discord_id: Some(Snowflake::new(1234)),
1205+
check_level: CheckLevel::JustOnline,
1206+
check_successful: Arc::new(AtomicBool::new(true)),
1207+
last_notified: Arc::new(AsyncMutex::new(None)),
1208+
};
1209+
1210+
let notifier = Arc::new(DiscordNotifier::new(
1211+
&server.url("/discord").parse().unwrap(),
1212+
));
1213+
let base_address = Intern::new(Uri::from_static("https://ring.purduehackers.com"));
1214+
1215+
for (i, &(site_status, should_notify, notify_status, delay)) in sequence.iter().enumerate()
1216+
{
1217+
// Create mock endpoints
1218+
let mut mock_bad_site = server.mock(|when, then| {
1219+
when.path("/site");
1220+
// Respond with the next status in the sequence
1221+
then.status(match site_status {
1222+
SiteStatus::Up => 200,
1223+
SiteStatus::Down => 404,
1224+
});
1225+
});
1226+
1227+
// Mocks the Discord notification endpoint; succeeds/fails based on the sequence.
1228+
let mut mock_discord_server = server.mock(|when, then| {
1229+
when.method(POST).path("/discord");
1230+
then.status(match notify_status {
1231+
Some(NotifyStatus::Success) => 204,
1232+
Some(NotifyStatus::Fail) => 500,
1233+
_ => 404,
1234+
});
1235+
});
1236+
1237+
// Perform check
1238+
let maybe_notification_task = member
1239+
.check_and_store_and_optionally_notify(base_address, Some(Arc::clone(&notifier)))
1240+
.await;
1241+
if let Some(notification_task) = maybe_notification_task {
1242+
// Wait for the notification task to complete
1243+
notification_task.await.unwrap();
1244+
}
1245+
match should_notify {
1246+
ShouldNotify::No => {
1247+
assert_eq!(
1248+
mock_discord_server.hits(),
1249+
0,
1250+
"expected no notification for step {i}"
1251+
);
1252+
}
1253+
ShouldNotify::Yes => {
1254+
assert_eq!(
1255+
mock_discord_server.hits(),
1256+
1,
1257+
"expected notification for step {i}"
1258+
);
1259+
}
1260+
}
1261+
// Check stored status
1262+
assert_eq!(
1263+
member.check_successful.load(Ordering::Relaxed),
1264+
site_status == SiteStatus::Up,
1265+
"check_successful mismatch at step {i}"
1266+
);
1267+
if let Some(delay) = delay {
1268+
tokio::time::pause();
1269+
tokio::time::advance(delay).await;
1270+
tokio::time::resume();
1271+
}
1272+
1273+
// Delete mock endpoints
1274+
mock_bad_site.delete();
1275+
mock_discord_server.delete();
1276+
}
1277+
}
11261278
}

0 commit comments

Comments
 (0)