use std::{sync::Arc, time::Duration};
use bestool_alertd::{AlertDefinition, InternalContext};
use bestool_postgres::pool::{PgPool, create_pool};
async fn setup_test_db(table_name: &str) -> (PgPool, String) {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests");
let pool = create_pool(&db_url, "bestool-alertd-test").await.unwrap();
let client = pool.get().await.unwrap();
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {} (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
value REAL NOT NULL,
error_count INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)",
table_name
);
client.execute(&create_sql, &[]).await.unwrap();
let delete_sql = format!("DELETE FROM {}", table_name);
client.execute(&delete_sql, &[]).await.unwrap();
(pool, table_name.to_string())
}
#[tokio::test]
async fn test_numerical_threshold_normal_trigger() {
let (pool, table_name) = setup_test_db("test_metrics_normal").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count) VALUES ('cpu_usage', 95.5, 10)",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT value FROM {} WHERE name = 'cpu_usage'"
numerical:
- field: value
alert-at: 90
clear-at: 50
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should trigger when value >= alert-at"
);
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
true,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should stay triggered when value > clear-at"
);
let client = ctx.pg_pool.get().await.unwrap();
let update_sql = format!(
"UPDATE {} SET value = 40 WHERE name = 'cpu_usage'",
table_name
);
client.execute(&update_sql, &[]).await.unwrap();
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
true,
)
.await
.unwrap();
assert!(
result.is_break(),
"Should clear when value <= clear-at (40 <= 50)"
);
}
#[tokio::test]
async fn test_numerical_threshold_inverted_trigger() {
let (pool, table_name) = setup_test_db("test_metrics_inverted").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count) VALUES ('free_space_gb', 5.0, 0)",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT value FROM {} WHERE name = 'free_space_gb'"
numerical:
- field: value
alert-at: 10
clear-at: 50
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should trigger when value <= alert-at (inverted)"
);
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
true,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should stay triggered when value < clear-at (inverted)"
);
let client = ctx.pg_pool.get().await.unwrap();
let update_sql = format!(
"UPDATE {} SET value = 60 WHERE name = 'free_space_gb'",
table_name
);
client.execute(&update_sql, &[]).await.unwrap();
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
true,
)
.await
.unwrap();
assert!(
result.is_break(),
"Should clear when value >= clear-at (60 >= 50, inverted)"
);
}
#[tokio::test]
async fn test_when_changed_simple() {
let (pool, table_name) = setup_test_db("test_metrics_changed_simple").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count) VALUES ('errors', 100.0, 5)",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT error_count FROM {} WHERE name = 'errors'"
when-changed: true
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
alert.execute(ctx.clone(), None, true, &[]).await.unwrap();
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let _ = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
assert!(tera_ctx.get("rows").is_some());
}
#[tokio::test]
async fn test_when_changed_with_except() {
let (pool, table_name) = setup_test_db("test_metrics_changed_except").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count, created_at, updated_at)
VALUES ('test', 100.0, 5, NOW(), NOW())",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT error_count, created_at, updated_at FROM {} WHERE name = 'test'"
when-changed:
except: [created_at, updated_at]
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let _ = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
let rows = tera_ctx.get("rows").unwrap();
assert!(!rows.as_array().unwrap().is_empty());
tokio::time::sleep(Duration::from_millis(10)).await;
let client = ctx.pg_pool.get().await.unwrap();
let update_sql = format!(
"UPDATE {} SET updated_at = NOW() WHERE name = 'test'",
table_name
);
client.execute(&update_sql, &[]).await.unwrap();
}
#[tokio::test]
async fn test_when_changed_with_only() {
let (pool, table_name) = setup_test_db("test_metrics_changed_only").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count) VALUES ('test', 100.0, 5)",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT error_count, value FROM {} WHERE name = 'test'"
when-changed:
only: [error_count]
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let _ = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
let client = ctx.pg_pool.get().await.unwrap();
let update_sql1 = format!("UPDATE {} SET value = 200 WHERE name = 'test'", table_name);
client.execute(&update_sql1, &[]).await.unwrap();
let update_sql2 = format!(
"UPDATE {} SET error_count = 10 WHERE name = 'test'",
table_name
);
client.execute(&update_sql2, &[]).await.unwrap();
let mut tera_ctx2 = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let _ = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx2,
false,
)
.await
.unwrap();
assert!(tera_ctx.get("rows").is_some());
assert!(tera_ctx2.get("rows").is_some());
}
#[tokio::test]
async fn test_numerical_and_when_changed_together() {
let (pool, table_name) = setup_test_db("test_metrics_combo").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count, created_at)
VALUES ('combo', 95.0, 100, NOW())",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT value, error_count, created_at FROM {} WHERE name = 'combo'"
numerical:
- field: value
alert-at: 90
clear-at: 50
when-changed:
except: [created_at]
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should trigger when numerical threshold exceeded"
);
assert!(tera_ctx.get("rows").is_some());
let rows = tera_ctx.get("rows").unwrap().as_array().unwrap();
assert_eq!(rows.len(), 1);
}
#[tokio::test]
async fn test_multiple_numerical_thresholds() {
let (pool, table_name) = setup_test_db("test_metrics_multi").await;
let client = pool.get().await.unwrap();
let insert_sql = format!(
"INSERT INTO {} (name, value, error_count) VALUES ('multi', 95.0, 150)",
table_name
);
client.execute(&insert_sql, &[]).await.unwrap();
let yaml = format!(
r#"
sql: "SELECT value as cpu, error_count as errors FROM {} WHERE name = 'multi'"
numerical:
- field: cpu
alert-at: 90
clear-at: 50
- field: errors
alert-at: 100
clear-at: 50
send:
- id: test
subject: Test
template: Test
"#,
table_name
);
let mut alert: AlertDefinition = serde_yaml::from_str(&yaml).unwrap();
alert.file = "test.yml".into();
let (alert, _) = alert.normalise(&Default::default()).unwrap();
let ctx = Arc::new(InternalContext {
pg_pool: pool,
http_client: reqwest::Client::new(),
canopy_client: None,
});
let mut tera_ctx = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx,
false,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should trigger when any threshold is exceeded"
);
let client = ctx.pg_pool.get().await.unwrap();
let update_sql1 = format!("UPDATE {} SET value = 40 WHERE name = 'multi'", table_name);
client.execute(&update_sql1, &[]).await.unwrap();
let mut tera_ctx2 = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx2,
true,
)
.await
.unwrap();
assert!(
result.is_continue(),
"Should stay triggered because errors threshold still exceeded"
);
let update_sql2 = format!(
"UPDATE {} SET error_count = 30 WHERE name = 'multi'",
table_name
);
client.execute(&update_sql2, &[]).await.unwrap();
let mut tera_ctx3 = bestool_alertd::templates::build_context(&alert, jiff::Timestamp::now());
let result = alert
.read_sources(
&ctx.pg_pool,
jiff::Timestamp::now() - alert.interval_duration,
&mut tera_ctx3,
true,
)
.await
.unwrap();
assert!(
result.is_break(),
"Should clear when all thresholds are below clear-at"
);
}