Files
boxmaker/crates/gatewayd/tests/serve.rs
T
kyle ed361db718 gatewayd: serve, the event loop, typing, catch-up and reconnecting
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-24 00:55:58 -07:00

214 lines
7.2 KiB
Rust

//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(
turns.recv_timeout(WAIT).unwrap().content,
"@boxmaker-straylight start"
);
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}