236 lines
8.6 KiB
Rust
236 lines
8.6 KiB
Rust
use rustbot::i18n::Locale;
|
|
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Channel};
|
|
|
|
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
|
|
let mut b = parse_board(net, store);
|
|
b.events = parse_events(log);
|
|
b
|
|
}
|
|
|
|
#[test]
|
|
fn parses_flat_number_format() {
|
|
let b = parse_board("libera", r#"{"romaka":2}"#);
|
|
assert_eq!(b.rows.len(), 1);
|
|
assert_eq!(b.rows[0].thing, "romaka");
|
|
assert_eq!(b.rows[0].count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_object_format_with_reason_and_by() {
|
|
let b = parse_board("t", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#);
|
|
assert_eq!(b.rows[0].count, 5);
|
|
assert_eq!(b.rows[0].reason.as_deref(), Some("clean PR"));
|
|
assert_eq!(b.rows[0].by.as_deref(), Some("alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn sorts_descending_by_count() {
|
|
let b = parse_board("t", r#"{"a":1,"b":9,"c":-3}"#);
|
|
let order: Vec<&str> = b.rows.iter().map(|r| r.thing.as_str()).collect();
|
|
assert_eq!(order, vec!["b", "a", "c"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_event_log_lines() {
|
|
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
|
|
{\"t\":200,\"o\":\"rust\",\"d\":-1,\"by\":\"bob\"}\n\
|
|
\n\
|
|
garbage line\n";
|
|
let ev = parse_events(log);
|
|
assert_eq!(ev.len(), 2);
|
|
assert_eq!(ev[0].thing, "rust");
|
|
assert_eq!(ev[0].delta, 1);
|
|
assert_eq!(ev[0].by.as_deref(), Some("alice"));
|
|
assert_eq!(ev[0].why.as_deref(), Some("clean PR"));
|
|
assert_eq!(ev[1].delta, -1);
|
|
assert!(ev[1].why.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn renders_hero_podium_and_scores() {
|
|
let b = parse_board(
|
|
"libera",
|
|
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
|
|
);
|
|
let html = render(&[b], 0, Locale::En);
|
|
assert!(html.contains(">karma<"), "wordmark missing");
|
|
assert!(html.contains("podium"), "no podium");
|
|
assert!(html.contains(">rust<"), "champion nick missing");
|
|
assert!(html.contains(">+5<"), "score missing");
|
|
assert!(html.contains("clean PR"), "reason missing");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_activity_and_givers_from_events() {
|
|
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
|
|
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
|
|
let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log);
|
|
let html = render(&[b], 0, Locale::En);
|
|
assert!(html.contains("recent activity"), "no activity section");
|
|
assert!(html.contains("top givers"), "no givers section");
|
|
assert!(html.contains("gave"), "no feed verb");
|
|
assert!(html.contains("2 gifts"), "giver count wrong");
|
|
assert!(html.contains("data-t=\"200\""), "feed newest-first missing");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_states_are_friendly() {
|
|
let html = render(&[], 0, Locale::En);
|
|
assert!(html.contains("No karma yet"), "{html}");
|
|
let b = parse_board("libera", r#"{"romaka":2}"#);
|
|
let html = render(&[b], 0, Locale::En);
|
|
assert!(html.contains("Nothing yet"), "empty activity state missing");
|
|
assert!(html.contains("Be the first"), "empty givers state missing");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_spanish_chrome() {
|
|
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"nadia\",\"why\":\"buen PR\"}\n";
|
|
let b = board_with_events("libera", r#"{"rust":1}"#, log);
|
|
let html = render(&[b], 0, Locale::Es);
|
|
assert!(html.contains("karma neto"), "es stat label missing");
|
|
assert!(
|
|
html.contains("actividad reciente"),
|
|
"es activity heading missing"
|
|
);
|
|
assert!(html.contains("top donantes"), "es givers heading missing");
|
|
assert!(html.contains("le dio a"), "es feed verb missing");
|
|
// relative-time config for the client is Spanish
|
|
assert!(html.contains("\"pre\":\"hace \""), "es time config missing");
|
|
// user content is never translated
|
|
assert!(html.contains("buen PR") && html.contains(">rust<"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_meta_channels_topic_and_users() {
|
|
let m = parse_meta(
|
|
r##"{"server":"irc.libera.chat","webchat":"https://web.libera.chat/","seen":123,"channels":[{"name":"#argentina","topic":"Bienvenidos","users":42},{"name":"#ar"}]}"##,
|
|
);
|
|
assert_eq!(m.server, "irc.libera.chat");
|
|
assert_eq!(m.webchat, "https://web.libera.chat/");
|
|
assert_eq!(m.seen, 123);
|
|
assert_eq!(m.channels.len(), 2);
|
|
assert_eq!(m.channels[0].name, "#argentina");
|
|
assert_eq!(m.channels[0].topic.as_deref(), Some("Bienvenidos"));
|
|
assert_eq!(m.channels[0].users, Some(42));
|
|
assert!(m.channels[1].topic.is_none() && m.channels[1].users.is_none());
|
|
// old bare-string format still parses
|
|
let old = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
|
|
assert_eq!(old.channels[0].name, "#c");
|
|
// garbage degrades to empty
|
|
let g = parse_meta("not json");
|
|
assert!(g.server.is_empty() && g.channels.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn renders_source_topic_users_join_and_online() {
|
|
let mut b = parse_board("libera", r#"{"romaka":2}"#);
|
|
b.server = "irc.libera.chat".to_string();
|
|
b.webchat = "https://web.libera.chat/".to_string();
|
|
b.seen = 1000;
|
|
b.channels = vec![Channel {
|
|
name: "#argentina".to_string(),
|
|
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
|
|
users: Some(42),
|
|
}];
|
|
let en = render(&[b], 1100, Locale::En); // now close to seen -> online
|
|
assert!(en.contains("#argentina"), "channel missing");
|
|
assert!(en.contains("irc.libera.chat"), "server missing");
|
|
assert!(en.contains("Argentina en Libera"), "topic missing");
|
|
assert!(
|
|
en.contains("<a href=\"https://qadlo.example/\""),
|
|
"topic url not linkified"
|
|
);
|
|
assert!(en.contains("42 online"), "user count missing");
|
|
assert!(
|
|
en.contains("https://web.libera.chat/#argentina"),
|
|
"join link missing"
|
|
);
|
|
assert!(en.contains("class=\"dot on\""), "online dot missing");
|
|
// og description present
|
|
assert!(
|
|
en.contains("property=\"og:description\""),
|
|
"og tags missing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn online_dot_goes_offline_when_stale() {
|
|
let mut b = parse_board("t", r#"{"x":1}"#);
|
|
b.seen = 1000;
|
|
let html = render(&[b], 9_999_999, Locale::En);
|
|
assert!(html.contains("class=\"dot off\""), "should be offline");
|
|
}
|
|
|
|
#[test]
|
|
fn topic_cannot_inject_markup() {
|
|
let mut b = parse_board("t", r#"{"x":1}"#);
|
|
b.channels = vec![Channel {
|
|
name: "#c".to_string(),
|
|
topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
|
|
users: None,
|
|
}];
|
|
let html = render(&[b], 0, Locale::En);
|
|
assert!(!html.contains("<script>evil"), "topic markup leaked");
|
|
assert!(!html.contains("href=\"javascript:"), "js scheme linkified");
|
|
assert!(html.contains("<script>evil"), "topic not escaped");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_analytics_sections() {
|
|
let now = 2_000_000u64;
|
|
let mut log = String::new();
|
|
for i in 0..12u64 {
|
|
let t = now - (12 - i) * 3600;
|
|
let why = if i == 0 {
|
|
",\"why\":\"clean code\""
|
|
} else {
|
|
""
|
|
};
|
|
log.push_str(&format!(
|
|
"{{\"t\":{t},\"o\":\"rust\",\"d\":1,\"by\":\"nadia\"{why}}}\n"
|
|
));
|
|
}
|
|
log.push_str(&format!(
|
|
"{{\"t\":{},\"o\":\"php\",\"d\":-1,\"by\":\"sam\",\"why\":\"footgun\"}}\n",
|
|
now - 1000
|
|
));
|
|
let b = board_with_events("libera", r#"{"rust":12,"php":-1}"#, &log);
|
|
let html = render(&[b], now, Locale::En);
|
|
assert!(html.contains("rising this week"), "no rising strip");
|
|
assert!(html.contains("karma over time"), "no chart");
|
|
assert!(html.contains("most debated"), "no most-debated");
|
|
assert!(
|
|
html.contains("milestones") && html.contains("reached"),
|
|
"no milestones"
|
|
);
|
|
assert!(html.contains("recent reasons"), "no reasons wall");
|
|
assert!(
|
|
html.contains("clean code") && html.contains("footgun"),
|
|
"reasons missing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_json_is_machine_readable() {
|
|
let b = parse_board("libera", r#"{"rust":{"n":5,"why":"clean","by":"a"}}"#);
|
|
let j = render_json(&[b], 42);
|
|
assert!(j.contains("\"generated\":42"), "{j}");
|
|
assert!(j.contains("\"network\":\"libera\""), "{j}");
|
|
assert!(j.contains("\"thing\":\"rust\",\"karma\":5"), "{j}");
|
|
assert!(j.contains("\"reason\":\"clean\""), "{j}");
|
|
}
|
|
|
|
#[test]
|
|
fn escapes_untrusted_irc_input_everywhere() {
|
|
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
|
|
let b = board_with_events(
|
|
"t",
|
|
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
|
|
log,
|
|
);
|
|
let html = render(&[b], 0, Locale::En);
|
|
assert!(!html.contains("<img"), "raw img tag leaked");
|
|
assert!(html.contains("<script>"), "nick not escaped");
|
|
assert!(html.contains("a&b"), "giver not escaped");
|
|
}
|