karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 01:27:39 +00:00
parent 26496b5147
commit 6af92c1217
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 714 additions and 101 deletions

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel};
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);
@ -102,51 +102,64 @@ fn renders_spanish_chrome() {
}
#[test]
fn parses_meta_channels_with_and_without_topic() {
// new format: objects with an optional topic
let (server, channels) = parse_meta(
r##"{"server":"irc.libera.chat","channels":[{"name":"#argentina","topic":"Bienvenidos"},{"name":"#ar"}]}"##,
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!(server, "irc.libera.chat");
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].name, "#argentina");
assert_eq!(channels[0].topic.as_deref(), Some("Bienvenidos"));
assert!(channels[1].topic.is_none());
// old format (bare strings) still parses
let (_, old) = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
assert_eq!(old[0].name, "#c");
assert!(old[0].topic.is_none());
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 (s, c) = parse_meta("not json");
assert!(s.is_empty() && c.is_empty());
let g = parse_meta("not json");
assert!(g.server.is_empty() && g.channels.is_empty());
}
#[test]
fn renders_source_channel_server_and_topic() {
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], 0, Locale::En);
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(">from<"), "from label missing");
// the topic text shows, and its URL becomes a safe link
assert!(en.contains("Argentina en Libera"), "topic text missing");
assert!(en.contains("Argentina en Libera"), "topic missing");
assert!(
en.contains("<a href=\"https://qadlo.example/\""),
"topic url not linkified: {en}"
"topic url not linkified"
);
// spanish label still works with no topic
let mut b2 = parse_board("libera", r#"{"romaka":2}"#);
b2.channels = vec![Channel {
name: "#argentina".to_string(),
topic: None,
}];
let es = render(&[b2], 0, Locale::Es);
assert!(es.contains(">desde<") && es.contains("#argentina"), "{es}");
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]
@ -155,6 +168,7 @@ fn topic_cannot_inject_markup() {
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");
@ -162,6 +176,51 @@ fn topic_cannot_inject_markup() {
assert!(html.contains("&lt;script&gt;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";