mod common;
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use common::*;
use semora_proto::{Level, Scope};
use semora_server::auth::{apikey, users};
use semora_server::projects::access::{self, ProjectAction};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
async fn push_doc(cli: &Cli) -> String {
cli.write("plan.md", "# Plan\n\n## Overview\n\nSome text.");
let out = cli.run(&["push", "plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
out.json()["id"].as_str().unwrap().to_string()
}
fn comment(text: &str) -> serde_json::Value {
json!({"type": "comment", "text": text,
"anchor": {"path": "plan.md", "quote": "Some text", "prefix": "", "suffix": ".", "heading": "Overview"}})
}
fn spawn(cli: &Cli, args: &[&str]) -> tokio::task::JoinHandle<Out> {
let mut cmd = cli.command(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
tokio::task::spawn_blocking(move || {
let o = cmd.output().unwrap();
Out {
code: o.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
}
})
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_collects_a_burst_prints_it_and_acks() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let waiting = spawn(
&cli,
&[
"wait",
&id,
"--timeout",
WAIT_TIMEOUT,
"--collect",
BURST_WINDOW,
"--json",
],
);
browser.wait_for_agent().await;
browser.send(comment("Reword this sentence.")).await;
browser
.send(json!({"type": "message", "text": "Also add a summary."}))
.await;
let out = waiting.await.unwrap();
assert_eq!(out.code, 0, "{}", out.stderr);
let v = out.json();
assert_eq!(v["doc_id"], id.as_str());
let events = v["events"].as_array().unwrap();
assert_eq!(events.len(), 2, "{v}");
assert_eq!(events[0]["kind"], "comment");
assert_eq!(events[0]["payload"]["anchor"]["heading"], "Overview");
assert_eq!(events[1]["payload"]["text"], "Also add a summary.");
until_pending(
&server,
&id,
&server.key,
"the burst was never acked",
|n| n == 0,
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_text_output_is_readable_for_agents() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let waiting = spawn(
&cli,
&["wait", &id, "--timeout", WAIT_TIMEOUT, "--collect", "0.5"],
);
browser.send(comment("Reword this sentence.")).await;
let out = waiting.await.unwrap();
assert_eq!(out.code, 0, "{}", out.stderr);
for needle in [
"comment",
"plan.md",
"Overview",
"Some text",
"Reword this sentence.",
&format!("semora say {id}"),
] {
assert!(
out.stdout.contains(needle),
"missing {needle:?} in:\n{}",
out.stdout
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn feedback_sent_while_no_agent_waits_is_delivered_later() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
browser
.next_of("hello", HUNG_MS)
.await
.expect("the server says hello");
browser
.send(json!({"type": "page_event", "name": "approve", "data": {"option": "B"}}))
.await;
until_pending(
&server,
&id,
&server.key,
"the event was never stored",
|n| n > 0,
)
.await;
let out = cli
.run(&[
"wait",
&id,
"--timeout",
WAIT_TIMEOUT,
"--collect",
"0.2",
"--json",
])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["events"][0]["payload"]["data"]["option"], "B");
}
#[tokio::test(flavor = "multi_thread")]
async fn follow_with_a_timeout_exits_0_when_it_printed_an_event_and_2_when_it_did_not() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let quiet = cli.run(&["wait", &id, "--follow", "--timeout", "1"]).await;
assert_eq!(quiet.code, 2, "no feedback at all: {}", quiet.stderr);
let mut browser = server.browser(&id).await;
browser
.next_of("hello", HUNG_MS)
.await
.expect("the server says hello");
browser.send(comment("one")).await;
until_pending(
&server,
&id,
&server.key,
"the comment was never stored",
|n| n == 1,
)
.await;
let out = cli.run(&["wait", &id, "--follow", "--timeout", "2"]).await;
assert_eq!(out.code, 0, "an event was printed: {}", out.stderr);
let line: serde_json::Value = serde_json::from_str(out.stdout.trim()).unwrap();
assert_eq!(line["payload"]["text"], "one");
assert!(!out.stderr.contains("no feedback"), "{}", out.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_exit_codes() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
let id = push_doc(&cli).await;
assert_eq!(
cli.run(&["wait", &id, "--timeout", "1"]).await.code,
2,
"timeout"
);
assert_eq!(
cli.run(&["wait", "AAAAAAAAAA", "--timeout", "1"])
.await
.code,
4,
"not found"
);
let ws_url = format!("{}/ws/d/{id}", server.url.replacen("http", "ws", 1));
assert_eq!(
cli.run(&["wait", &ws_url, "--timeout", "1"]).await.code,
2,
"websocket URLs work too"
);
cli.key = Some("sm_AAAAAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into());
assert_eq!(
cli.run(&["wait", &id, "--timeout", "1"]).await.code,
3,
"auth"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_downloads_screenshots() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let png = b"\x89PNG\r\n\x1a\nscreenshot-bytes".to_vec();
let resp = reqwest::Client::new()
.post(format!("{}/api/v1/docs/{id}/attachments", server.url))
.header("cookie", &browser.cookie)
.header("origin", &server.url)
.header("x-csrf-token", &browser.csrf)
.header("content-type", "image/png")
.body(png.clone())
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let attachment = resp.json::<serde_json::Value>().await.unwrap()["attachment_id"]
.as_str()
.unwrap()
.to_string();
let save = cli.path("shots");
let save_s = save.to_string_lossy().into_owned();
let waiting = spawn(
&cli,
&[
"wait",
&id,
"--timeout",
WAIT_TIMEOUT,
"--collect",
"0.3",
"--json",
"--save-dir",
&save_s,
],
);
browser.wait_for_agent().await;
browser
.send(json!({"type": "drawing", "path": "plan.md", "note": "this box", "attachment_id": attachment,
"viewport": {"width": 800, "height": 600}, "scroll": {"x": 0, "y": 0}}))
.await;
let out = waiting.await.unwrap();
assert_eq!(out.code, 0, "{}", out.stderr);
let ev = &out.json()["events"][0];
let path = ev["attachment_path"].as_str().expect("screenshot saved");
assert_eq!(std::fs::read(path).unwrap(), png);
assert!(path.starts_with(&save_s));
}
#[tokio::test(flavor = "multi_thread")]
async fn follow_streams_json_lines() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let mut cmd = cli.command(&["wait", &id, "--follow"]);
cmd.stdout(Stdio::piped());
let mut child = cmd.spawn().unwrap();
let stdout = child.stdout.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::BufRead;
for line in std::io::BufReader::new(stdout)
.lines()
.map_while(Result::ok)
{
let _ = tx.send(line);
}
});
browser.wait_for_agent().await;
browser.send(comment("one")).await;
browser.send(comment("two")).await;
let mut got = Vec::new();
let deadline = std::time::Instant::now() + HUNG;
while got.len() < 2 && std::time::Instant::now() < deadline {
match rx.try_recv() {
Ok(line) => got.push(serde_json::from_str::<serde_json::Value>(&line).unwrap()),
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
child.kill().unwrap();
let _ = child.wait();
assert_eq!(got.len(), 2);
assert_eq!(got[0]["payload"]["text"], "one");
assert_eq!(got[1]["payload"]["text"], "two");
}
#[tokio::test(flavor = "multi_thread")]
async fn say_shows_a_message_in_the_page() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
browser
.next_of("hello", HUNG_MS)
.await
.expect("the server says hello");
let out = cli
.run(&["say", &id, "On it — fixing the intro now."])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
let s = browser
.next_of("say", HUNG_MS)
.await
.expect("page gets the message");
assert_eq!(s["text"], "On it — fixing the intro now.");
}
#[tokio::test(flavor = "multi_thread")]
async fn push_with_wait_publishes_then_waits() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("draft.md", "# Draft");
let waiting = spawn(
&cli,
&[
"push",
"draft.md",
"--wait",
"--json",
"--timeout",
WAIT_TIMEOUT,
"--collect",
"0.3",
],
);
let deadline = std::time::Instant::now() + HUNG;
while cli.cache().is_empty() && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
}
let id = cli.cache()[0][2].clone();
let mut browser = server.browser(&id).await;
browser.wait_for_agent().await;
browser
.send(json!({"type": "message", "text": "ship it"}))
.await;
let out = waiting.await.unwrap();
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["events"][0]["payload"]["text"], "ship it");
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_prints_who_sent_each_event() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let waiting = spawn(
&cli,
&[
"wait",
&id,
"--timeout",
WAIT_TIMEOUT,
"--collect",
BURST_WINDOW,
],
);
browser.wait_for_agent().await;
browser.send(comment("Reword this sentence.")).await;
browser
.send(json!({"type": "message", "text": "Also add a summary."}))
.await;
let out = waiting.await.unwrap();
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stdout.matches(" from Admin]").count(),
2,
"{}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_json_carries_from() {
let server = Server::start().await;
let cli = Cli::new(&server);
let id = push_doc(&cli).await;
let mut browser = server.browser(&id).await;
let waiting = spawn(
&cli,
&[
"wait",
&id,
"--timeout",
WAIT_TIMEOUT,
"--collect",
"0.5",
"--json",
],
);
browser.wait_for_agent().await;
browser.send(comment("Reword this sentence.")).await;
let out = waiting.await.unwrap();
let v = out.json();
assert_eq!(v["events"][0]["from"]["name"], "Admin");
assert_eq!(v["events"][0]["from"]["id"], server.user_id);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_as_a_reviewer_exits_3_with_the_level_message() {
let server = Server::start().await;
let cli = Cli::new(&server);
assert_eq!(
cli.run(&["project", "init", "--name", "proj"]).await.code,
0
);
cli.write("docs/plan.md", "# Plan");
let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let id = out.json()["id"].as_str().unwrap().to_string();
let bo = users::create(
&server.state.db,
"bo@example.com",
"Bo",
"correct horse battery",
false,
)
.await
.unwrap();
let (_, bo_key) = apikey::create(&server.state, bo.id, "bo", &Scope::all(), None, "web")
.await
.unwrap();
let project = access::project_for(
&server.state.db,
server.user_id,
"proj",
ProjectAction::View,
)
.await
.unwrap();
semora_server::collab::add_collaborator(
&server.state.db,
project.id,
bo.id,
Level::Reviewer,
Some(server.user_id),
)
.await
.unwrap();
let mut bo_cli = Cli::new(&server);
bo_cli.key = Some(bo_key);
let out = bo_cli.run(&["wait", &id, "--timeout", "5"]).await;
assert_eq!(out.code, 3, "{}", out.stderr);
assert!(
out.stderr.contains("needs the Editor level"),
"{}",
out.stderr
);
}
#[derive(Clone, Copy)]
enum Mode {
Open,
Deaf,
Refuse,
Hang,
}
const WAIT_TIMEOUT: &str = "120";
const BURST_WINDOW: &str = "10";
const HUNG_MS: u64 = HUNG.as_millis() as u64;
const HELLO: &[u8] = br#""type":"hello""#;
const EVENT: &[u8] = br#""type":"event""#;
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
async fn until(what: &str, mut ready: impl FnMut() -> bool) {
let deadline = std::time::Instant::now() + HUNG;
while !ready() {
assert!(std::time::Instant::now() < deadline, "{what}");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
async fn until_pending(
server: &Server,
id: &str,
token: &str,
what: &str,
ready: impl Fn(usize) -> bool,
) {
let deadline = std::time::Instant::now() + HUNG;
while !ready(pending_for(server, id, token).await.len()) {
assert!(std::time::Instant::now() < deadline, "{what}");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
enum Cut {
Idle,
Armed(Mode),
Done,
}
struct Proxy {
url: String,
mode: Arc<Mutex<Mode>>,
from_server: Arc<Mutex<Vec<u8>>>,
relays: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
held: Arc<Mutex<Vec<TcpStream>>>,
cut: Arc<Mutex<Cut>>,
restore_on: Arc<Mutex<Option<Arc<AtomicBool>>>>,
accept: tokio::task::JoinHandle<()>,
}
async fn relay(
mut client: TcpStream,
target: String,
from_server: Arc<Mutex<Vec<u8>>>,
mode: Arc<Mutex<Mode>>,
cut: Arc<Mutex<Cut>>,
) {
let Ok(mut server) = TcpStream::connect(&target).await else {
return;
};
let (mut client_in, mut client_out) = client.split();
let (mut server_in, mut server_out) = server.split();
let severed = AtomicBool::new(false);
let up = async {
let mut buf = [0u8; 8192];
loop {
match client_in.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
let deaf = matches!(*mode.lock().unwrap(), Mode::Deaf);
let severed = severed.load(Ordering::SeqCst);
if !(deaf || severed) && server_out.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
};
let down = async {
let mut buf = [0u8; 8192];
let mut sent = Vec::new();
loop {
match server_in.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
from_server.lock().unwrap().extend_from_slice(&buf[..n]);
if client_out.write_all(&buf[..n]).await.is_err() {
return;
}
sent.extend_from_slice(&buf[..n]);
let outage = match *cut.lock().unwrap() {
Cut::Armed(outage) if contains(&sent, EVENT) => Some(outage),
_ => None,
};
if let Some(outage) = outage {
*mode.lock().unwrap() = outage;
severed.store(true, Ordering::SeqCst);
*cut.lock().unwrap() = Cut::Done;
let _ = client_out.shutdown().await;
std::future::pending::<()>().await;
}
}
}
}
};
tokio::select! { _ = up => {}, _ = down => {} }
}
impl Proxy {
async fn start(server_url: &str) -> Proxy {
let target = server_url.trim_start_matches("http://").to_string();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let mode = Arc::new(Mutex::new(Mode::Open));
let from_server = Arc::new(Mutex::new(Vec::new()));
let relays = Arc::new(Mutex::new(Vec::new()));
let held = Arc::new(Mutex::new(Vec::new()));
let cut = Arc::new(Mutex::new(Cut::Idle));
let restore_on = Arc::new(Mutex::new(None::<Arc<AtomicBool>>));
let accept = tokio::spawn({
let (mode, from_server) = (mode.clone(), from_server.clone());
let (relays, held, cut) = (relays.clone(), held.clone(), cut.clone());
let restore_on = restore_on.clone();
async move {
while let Ok((client, _)) = listener.accept().await {
let signal = restore_on.lock().unwrap().clone();
if signal.is_some_and(|s| s.load(Ordering::SeqCst)) {
*restore_on.lock().unwrap() = None;
*mode.lock().unwrap() = Mode::Open;
held.lock().unwrap().clear();
}
let now = *mode.lock().unwrap();
match now {
Mode::Open | Mode::Deaf => {
let task = tokio::spawn(relay(
client,
target.clone(),
from_server.clone(),
mode.clone(),
cut.clone(),
));
relays.lock().unwrap().push(task);
}
Mode::Refuse => drop(client),
Mode::Hang => held.lock().unwrap().push(client),
}
}
}
});
Proxy {
url,
mode,
from_server,
relays,
held,
cut,
restore_on,
accept,
}
}
fn outage(&self, mode: Mode) {
*self.mode.lock().unwrap() = mode;
self.held.lock().unwrap().clear();
for task in self.relays.lock().unwrap().drain(..) {
task.abort();
}
}
fn restore(&self) {
*self.mode.lock().unwrap() = Mode::Open;
self.held.lock().unwrap().clear();
}
fn restore_once(&self, signal: Arc<AtomicBool>) {
*self.restore_on.lock().unwrap() = Some(signal);
}
fn go_deaf(&self) {
*self.mode.lock().unwrap() = Mode::Deaf;
}
async fn wait_for_hello(&self) {
until("the server never said hello to the client", || {
contains(&self.from_server.lock().unwrap(), HELLO)
})
.await;
}
fn cut_after_event(&self, mode: Mode) {
*self.cut.lock().unwrap() = Cut::Armed(mode);
}
async fn wait_for_cut(&self) {
until("the event never reached the client", || {
matches!(*self.cut.lock().unwrap(), Cut::Done)
})
.await;
}
}
impl Drop for Proxy {
fn drop(&mut self) {
self.accept.abort();
for task in self.relays.lock().unwrap().drain(..) {
task.abort();
}
}
}
struct Running {
child: std::process::Child,
lines: std::sync::mpsc::Receiver<String>,
stderr: Option<std::thread::JoinHandle<String>>,
stdout: String,
printed: Arc<AtomicBool>,
_cli: Cli,
}
impl Running {
fn start(cli: Cli, args: &[&str]) -> Running {
let mut cmd = cli.command(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
let stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let (tx, lines) = std::sync::mpsc::channel();
let printed = Arc::new(AtomicBool::new(false));
std::thread::spawn({
let printed = printed.clone();
move || {
use std::io::BufRead;
for line in std::io::BufReader::new(stdout)
.lines()
.map_while(Result::ok)
{
printed.store(true, Ordering::SeqCst);
let _ = tx.send(line);
}
}
});
let stderr = std::thread::spawn(move || {
use std::io::Read;
let mut text = String::new();
let _ = stderr.read_to_string(&mut text);
text
});
Running {
child,
lines,
stderr: Some(stderr),
stdout: String::new(),
printed,
_cli: cli,
}
}
fn printed(&self) -> Arc<AtomicBool> {
self.printed.clone()
}
async fn next_line(&mut self, within: Duration) -> Option<String> {
let deadline = std::time::Instant::now() + within;
loop {
match self.lines.try_recv() {
Ok(line) => {
self.stdout.push_str(&line);
self.stdout.push('\n');
return Some(line);
}
Err(std::sync::mpsc::TryRecvError::Empty)
if std::time::Instant::now() < deadline =>
{
tokio::time::sleep(Duration::from_millis(25)).await;
}
Err(_) => return None,
}
}
}
async fn stop(mut self) -> Out {
let _ = self.child.kill();
self.finish().await
}
async fn finish(mut self) -> Out {
tokio::task::spawn_blocking(move || {
let status = self.child.wait().unwrap();
for line in self.lines.iter() {
self.stdout.push_str(&line);
self.stdout.push('\n');
}
Out {
code: status.code().unwrap_or(-1),
stdout: std::mem::take(&mut self.stdout),
stderr: self.stderr.take().unwrap().join().unwrap(),
}
})
.await
.unwrap()
}
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
async fn holding_an_event(
server: &Server,
mut cli: Cli,
proxy: &Proxy,
id: &str,
opts: &[&str],
outage: Mode,
) -> Running {
cli.url = proxy.url.clone();
let mut browser = server.browser(id).await;
let args: Vec<&str> = ["wait", id]
.into_iter()
.chain(opts.iter().copied())
.collect();
let run = Running::start(cli, &args);
proxy.wait_for_hello().await;
proxy.cut_after_event(outage);
browser.send(comment("Reword this sentence.")).await;
proxy.wait_for_cut().await;
run
}
async fn wait_holding_an_event(server: &Server, proxy: &Proxy, id: &str, outage: Mode) -> Running {
let opts = ["--timeout", WAIT_TIMEOUT, "--collect", "1"];
holding_an_event(server, Cli::new(server), proxy, id, &opts, outage).await
}
#[tokio::test(flavor = "multi_thread")]
async fn a_burst_that_closes_during_an_outage_is_printed_then_and_acked_when_the_server_returns() {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let mut run = wait_holding_an_event(&server, &proxy, &id, Mode::Refuse).await;
proxy.restore_once(run.printed());
let head = run
.next_line(HUNG)
.await
.expect("the burst is printed while the server is out");
assert!(head.starts_with("1 event on"), "{head}");
let out = run.finish().await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Reword this sentence."),
"{}",
out.stdout
);
assert!(!out.stderr.contains("not acked"), "{}", out.stderr);
until_pending(
&server,
&id,
&server.key,
"the burst was never acked",
|n| n == 0,
)
.await;
}
async fn printed_in_an_outage_that_does_not_end(mode: Mode) {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let mut run = wait_holding_an_event(&server, &proxy, &id, mode).await;
let head = run
.next_line(HUNG)
.await
.expect("the burst is printed when its window closes, not at the 120 s timeout");
assert!(head.starts_with("1 event on"), "{head}");
let out = run.finish().await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stderr.matches("printed but not acked").count(),
1,
"{}",
out.stderr
);
assert!(
out.stderr.contains(&format!(
"the next `semora wait {id}` delivers it again unless the ack reached the server"
)),
"{}",
out.stderr
);
assert_eq!(
pending_for(&server, &id, &server.key).await.len(),
1,
"an ack reached the server while it was out of reach"
);
let out = direct
.run(&["wait", &id, "--timeout", WAIT_TIMEOUT, "--collect", "0.2"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Reword this sentence."),
"the event was delivered again: {}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_ack_that_the_server_does_not_confirm_is_said() {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let mut cli = Cli::new(&server);
cli.url = proxy.url.clone();
let mut browser = server.browser(&id).await;
let run = Running::start(
cli,
&["wait", &id, "--timeout", WAIT_TIMEOUT, "--collect", "1"],
);
proxy.wait_for_hello().await;
proxy.go_deaf();
browser.send(comment("Reword this sentence.")).await;
let out = run.finish().await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Reword this sentence."),
"{}",
out.stdout
);
assert_eq!(
out.stderr
.matches("the server did not confirm the ack within a second")
.count(),
1,
"{}",
out.stderr
);
assert!(!out.stderr.contains("not acked"), "{}", out.stderr);
assert_eq!(
pending_for(&server, &id, &server.key).await.len(),
1,
"the ack went nowhere, so the event is still pending"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_burst_printed_during_an_outage_warns_when_no_ack_gets_through() {
printed_in_an_outage_that_does_not_end(Mode::Refuse).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_server_that_never_answers_does_not_hold_the_burst_window_open() {
printed_in_an_outage_that_does_not_end(Mode::Hang).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn the_ack_retry_does_not_run_past_the_timeout() {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let opts = ["--timeout", "3", "--collect", "0.2"];
let mut run =
holding_an_event(&server, Cli::new(&server), &proxy, &id, &opts, Mode::Refuse).await;
let head = run.next_line(HUNG).await.expect("the burst is printed");
assert!(head.starts_with("1 event on"), "{head}");
let printed = std::time::Instant::now();
let out = run.finish().await;
let retried = printed.elapsed();
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stderr.matches("printed but not acked").count(),
1,
"{}",
out.stderr
);
assert!(
retried < Duration::from_secs(4),
"the ack retry ran for {retried:?} after a 3 s timeout"
);
}
async fn pending_for(server: &Server, id: &str, token: &str) -> Vec<i64> {
let (key, _) = apikey::authenticate(&server.state.db, token)
.await
.unwrap()
.expect("the key is valid");
semora_server::events::pending(&server.state, id, key.id, &[])
.await
.unwrap()
.into_iter()
.map(|e| e.id)
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn follow_acks_an_event_again_when_the_server_sends_it_again() {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let mut cli = Cli::new(&server);
cli.url = proxy.url.clone();
let mut browser = server.browser(&id).await;
let mut run = Running::start(cli, &["wait", &id, "--follow"]);
browser.wait_for_agent().await;
proxy.go_deaf();
browser.send(comment("Reword this sentence.")).await;
let line = run.next_line(HUNG).await.expect("follow prints the event");
assert!(line.contains("Reword this sentence."), "{line}");
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
pending_for(&server, &id, &server.key).await.len(),
1,
"the ack was lost, so the event is still pending"
);
proxy.outage(Mode::Refuse);
proxy.restore();
until_pending(
&server,
&id,
&server.key,
"follow never acked the event it was sent again",
|n| n == 0,
)
.await;
let out = run.stop().await;
assert_eq!(
out.stdout.matches("Reword this sentence.").count(),
1,
"the event is printed once: {}",
out.stdout
);
}
#[derive(Clone, Copy)]
enum Refusal {
KeyRevoked,
DocumentDeleted,
}
impl Refusal {
fn code(self) -> i32 {
match self {
Refusal::KeyRevoked => 3,
Refusal::DocumentDeleted => 4,
}
}
fn words(self) -> &'static str {
match self {
Refusal::KeyRevoked => "API key was rejected",
Refusal::DocumentDeleted => "not found",
}
}
}
#[derive(Clone, Copy)]
enum Held {
Nothing,
Burst,
Followed,
}
async fn refused_after_an_outage(refusal: Refusal, held: Held) {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let (key, token) = apikey::create(
&server.state,
server.user_id,
"waiter",
&Scope::all(),
None,
"web",
)
.await
.unwrap();
let mut cli = Cli::new(&server);
cli.key = Some(token.clone());
cli.url = proxy.url.clone();
let mut run = match held {
Held::Burst => {
let opts = ["--timeout", "240", "--collect", "120"];
holding_an_event(&server, cli, &proxy, &id, &opts, Mode::Refuse).await
}
Held::Nothing => {
let run = Running::start(cli, &["wait", &id, "--timeout", WAIT_TIMEOUT]);
proxy.wait_for_hello().await;
run
}
Held::Followed => {
let mut browser = server.browser(&id).await;
let mut run =
Running::start(cli, &["wait", &id, "--follow", "--timeout", WAIT_TIMEOUT]);
proxy.wait_for_hello().await;
browser.send(comment("Reword this sentence.")).await;
let line = run.next_line(HUNG).await.expect("follow prints the event");
assert!(line.contains("Reword this sentence."), "{line}");
until_pending(&server, &id, &token, "follow never acked the event", |n| {
n == 0
})
.await;
run
}
};
proxy.outage(Mode::Refuse);
match refusal {
Refusal::KeyRevoked => {
assert!(
apikey::revoke(&server.state.db, None, key.id)
.await
.unwrap()
);
}
Refusal::DocumentDeleted => assert_eq!(direct.run(&["rm", &id]).await.code, 0),
}
proxy.restore();
match held {
Held::Burst => {
let head = run
.next_line(HUNG)
.await
.expect("the burst is printed when the server refuses the reconnect");
assert!(head.starts_with("1 event on"), "{head}");
let out = run.finish().await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Reword this sentence."),
"{}",
out.stdout
);
assert_eq!(
out.stderr.matches("printed but not acked").count(),
1,
"{}",
out.stderr
);
assert!(out.stderr.contains(refusal.words()), "{}", out.stderr);
}
Held::Nothing | Held::Followed => {
let out = run.finish().await;
assert_eq!(out.code, refusal.code(), "{}", out.stderr);
assert!(out.stderr.contains(refusal.words()), "{}", out.stderr);
assert!(!out.stderr.contains("not acked"), "{}", out.stderr);
let printed = out.stdout.matches("Reword this sentence.").count();
assert_eq!(printed, usize::from(matches!(held, Held::Followed)));
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_burst_is_printed_when_the_reconnect_finds_the_key_revoked() {
refused_after_an_outage(Refusal::KeyRevoked, Held::Burst).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_burst_is_printed_when_the_reconnect_finds_the_document_deleted() {
refused_after_an_outage(Refusal::DocumentDeleted, Held::Burst).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn with_no_burst_a_revoked_key_on_reconnect_exits_3() {
refused_after_an_outage(Refusal::KeyRevoked, Held::Nothing).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn with_no_burst_a_deleted_document_on_reconnect_exits_4() {
refused_after_an_outage(Refusal::DocumentDeleted, Held::Nothing).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn follow_exits_3_when_the_reconnect_finds_the_key_revoked() {
refused_after_an_outage(Refusal::KeyRevoked, Held::Followed).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn follow_exits_4_when_the_reconnect_finds_the_document_deleted() {
refused_after_an_outage(Refusal::DocumentDeleted, Held::Followed).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_refused_ack_after_an_outage_does_not_promise_the_events_again() {
let server = Server::start().await;
let direct = Cli::new(&server);
let id = push_doc(&direct).await;
let proxy = Proxy::start(&server.url).await;
let (key, token) = apikey::create(
&server.state,
server.user_id,
"waiter",
&Scope::all(),
None,
"web",
)
.await
.unwrap();
let mut cli = Cli::new(&server);
cli.key = Some(token);
let opts = ["--timeout", WAIT_TIMEOUT, "--collect", "1"];
let mut run = holding_an_event(&server, cli, &proxy, &id, &opts, Mode::Refuse).await;
assert!(
apikey::revoke(&server.state.db, None, key.id)
.await
.unwrap()
);
proxy.restore_once(run.printed());
let head = run
.next_line(HUNG)
.await
.expect("the burst is printed when its window closes");
assert!(head.starts_with("1 event on"), "{head}");
let out = run.finish().await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(
out.stderr.matches("printed but not acked").count(),
1,
"{}",
out.stderr
);
assert!(
out.stderr.contains(Refusal::KeyRevoked.words()),
"{}",
out.stderr
);
assert!(!out.stderr.contains("delivers"), "{}", out.stderr);
assert!(!out.stderr.contains("unless"), "{}", out.stderr);
}