d9359ba93d
- engine: live.join, live.edit, live.poll, live.peers, live.selection, live.info and live.leave over the live session client (at most 8 rooms, no token in any answer, dropping a session with open rooms returns within 2 s); server.file_meta names the last editor of a file - engine: sync.pending lists the local changes the server does not have yet (new, edited, deleted; at most 500, local comparison only) - ui-egui: a "Waiting to sync" queue sheet opened from the sync sheet, and the "You are offline" banner on the compact Notes page - tests: two sessions converge through the real server using live.* commands; stub-server, queue and banner tests Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
277 lines
12 KiB
Rust
277 lines
12 KiB
Rust
//! The `live.*` commands against a stub server: the rooms they open, the
|
|
//! events they drain, the rooms they refuse and the inputs they survive.
|
|
|
|
mod common;
|
|
mod live_stub;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::temp_vault;
|
|
use live_stub::Stub;
|
|
use notescraft_engine::{Error, Registry, Session, bind_folder, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
/// The token of the bound vault; it must never reach an answer or an error.
|
|
const TOKEN: &str = "secret-token";
|
|
|
|
/// How long a test waits for the room to answer.
|
|
const DEADLINE: Duration = Duration::from_secs(10);
|
|
|
|
/// The most rooms one session may hold open.
|
|
const MAX_ROOMS: usize = 8;
|
|
|
|
/// A registry with the core commands.
|
|
fn registry() -> Registry<Session> {
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
reg
|
|
}
|
|
|
|
/// A vault bound to the stub, with the registry and the session.
|
|
fn bound(name: &str, stub: &Stub) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
bind_folder(vault.path(), &stub.base, "vault-1", TOKEN, "device-1", None).expect("bind the vault");
|
|
let reg = registry();
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(reg, session, vault)
|
|
}
|
|
|
|
/// The code of a command error.
|
|
fn code(error: &Error) -> String {
|
|
match error {
|
|
Error::Command { source, .. } => source.code.clone(),
|
|
Error::InvalidParams { .. } => "invalid_params".to_owned(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The message of a command error.
|
|
fn message(error: &Error) -> String {
|
|
match error {
|
|
Error::Command { source, .. } => source.message.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Waits until the stub opened that many event streams.
|
|
fn wait_for_streams(stub: &Stub, count: usize) {
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.stream_count() < count && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
/// Polls the room until the events satisfy `wanted`, or the deadline ends.
|
|
fn poll_until(reg: &Registry<Session>, session: &mut Session, wanted: impl Fn(&[Value]) -> bool) -> Value {
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while Instant::now() < deadline {
|
|
let answer = reg.run("live.poll", session, &json!({ "path": "note.md" })).expect("poll");
|
|
let events = answer["events"].as_array().cloned().unwrap_or_default();
|
|
if wanted(&events) {
|
|
return answer;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
panic!("the room did not answer in time");
|
|
}
|
|
|
|
/// The events of the last poll.
|
|
fn events(answer: &Value) -> Vec<Value> {
|
|
answer["events"].as_array().cloned().unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn joining_a_note_takes_the_text_of_the_room() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-join", &stub);
|
|
let answer = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
assert_eq!(answer["text"], json!("hello"), "the room hands its text to the client");
|
|
assert_eq!(answer["already"], json!(false));
|
|
assert_eq!(answer["rev"], json!(0));
|
|
assert_eq!(answer["peers"], json!([]));
|
|
assert_eq!(stub.joins().len(), 1, "the client joined once");
|
|
assert!(!answer.to_string().contains(TOKEN), "the token never reaches an answer");
|
|
}
|
|
|
|
#[test]
|
|
fn joining_twice_says_already() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-already", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
let again = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join again");
|
|
assert_eq!(again["already"], json!(true), "the second join keeps the room");
|
|
assert_eq!(again["text"], json!("hello"));
|
|
assert_eq!(stub.joins().len(), 1, "the room was joined once");
|
|
}
|
|
|
|
#[test]
|
|
fn an_edit_posts_one_operation() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-edit", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
let answer = reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": "hello!" })).expect("edit");
|
|
assert!(answer["rev"].is_number(), "{answer:?}");
|
|
|
|
let deadline = Instant::now() + DEADLINE;
|
|
while stub.ops().is_empty() && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let ops = stub.ops();
|
|
assert_eq!(ops.len(), 1, "the edit went out once");
|
|
let body: Value = serde_json::from_str(&ops[0].body).expect("the body of the operation");
|
|
assert_eq!(body["op"], json!([5, "!"]), "the operation is in the wire format");
|
|
assert_eq!(body["base_rev"], json!(0), "the operation is based on the revision of the room");
|
|
assert!(ops[0].path.ends_with("/live/note.md/ops"), "{}", ops[0].path);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_edit_reaches_the_poll() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-poll", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
wait_for_streams(&stub, 1);
|
|
stub.push_event("event: op\ndata: {\"rev\":1,\"client\":\"b\",\"op\":[5,\"!\"]}\n\n");
|
|
|
|
let answer = poll_until(®, &mut session, |events| events.iter().any(|event| event["kind"] == json!("remote")));
|
|
assert_eq!(answer["text"], json!("hello!"), "the operation of the peer reached the mirror");
|
|
let events = events(&answer);
|
|
assert!(events.iter().any(|event| event["kind"] == json!("remote")), "{events:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_closed_room_is_removed() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-closed", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
// The server refuses the operation: the client gives up.
|
|
stub.set_ops_status(500);
|
|
reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": "hello!" })).expect("edit");
|
|
|
|
let answer = poll_until(®, &mut session, |events| events.iter().any(|event| event["kind"] == json!("closed")));
|
|
let events = events(&answer);
|
|
assert!(events.iter().any(|event| event["kind"] == json!("closed")), "{events:?}");
|
|
|
|
let error = reg.run("live.poll", &mut session, &json!({ "path": "note.md" })).expect_err("the room is gone");
|
|
assert_eq!(code(&error), "not_joined");
|
|
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
|
|
assert_eq!(info["rooms"], json!([]), "the closed room left the session");
|
|
}
|
|
|
|
#[test]
|
|
fn a_ninth_room_is_refused() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-limit", &stub);
|
|
for index in 0..MAX_ROOMS {
|
|
let path = format!("note-{index}.md");
|
|
reg.run("live.join", &mut session, &json!({ "path": path })).expect("join");
|
|
}
|
|
let error = reg.run("live.join", &mut session, &json!({ "path": "note-8.md" })).expect_err("the ninth room");
|
|
assert_eq!(code(&error), "limit");
|
|
assert!(message(&error).contains("8 rooms"), "{}", message(&error));
|
|
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
|
|
assert_eq!(info["rooms"].as_array().map(Vec::len), Some(MAX_ROOMS));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unbound_vault_cannot_join() {
|
|
let stub = Stub::start("hello");
|
|
let vault = temp_vault("live-unbound");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
let reg = registry();
|
|
let error = reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect_err("join");
|
|
assert_eq!(code(&error), "not_bound");
|
|
assert!(message(&error).contains("not bound"), "{}", message(&error));
|
|
assert!(stub.joins().is_empty(), "no request was made");
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_without_a_room_is_refused() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-not-joined", &stub);
|
|
let cases: [(&str, Value); 5] = [
|
|
("live.edit", json!({ "path": "note.md", "text": "x" })),
|
|
("live.poll", json!({ "path": "note.md" })),
|
|
("live.peers", json!({ "path": "note.md" })),
|
|
("live.selection", json!({ "path": "note.md", "anchor": 0, "head": 0 })),
|
|
("live.leave", json!({ "path": "note.md" })),
|
|
];
|
|
for (id, params) in cases {
|
|
let error = reg.run(id, &mut session, ¶ms).expect_err("no room");
|
|
assert_eq!(code(&error), "not_joined", "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_info_and_the_peers_describe_the_room() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-info", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
|
|
assert_eq!(info["rooms"][0]["path"], json!("note.md"));
|
|
assert_eq!(info["rooms"][0]["rev"], json!(0));
|
|
assert_eq!(info["rooms"][0]["peers"], json!([]));
|
|
let peers = reg.run("live.peers", &mut session, &json!({ "path": "note.md" })).expect("peers");
|
|
assert_eq!(peers["peers"], json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_selection_is_accepted_by_the_room() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-selection", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
let answer = reg.run("live.selection", &mut session, &json!({ "path": "note.md", "anchor": 2, "head": 4 })).expect("selection");
|
|
assert_eq!(answer["anchor"], json!(2));
|
|
assert_eq!(answer["head"], json!(4));
|
|
}
|
|
|
|
#[test]
|
|
fn leaving_a_room_closes_it() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-leave", &stub);
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
let answer = reg.run("live.leave", &mut session, &json!({ "path": "note.md" })).expect("leave");
|
|
assert_eq!(answer["left"], json!(true));
|
|
let info = reg.run("live.info", &mut session, &Value::Null).expect("info");
|
|
assert_eq!(info["rooms"], json!([]), "the room left the session");
|
|
let error = reg.run("live.poll", &mut session, &json!({ "path": "note.md" })).expect_err("the room is gone");
|
|
assert_eq!(code(&error), "not_joined");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_params_are_refused() {
|
|
let stub = Stub::start("hello");
|
|
let (reg, mut session, _vault) = bound("live-hostile", &stub);
|
|
let cases = [
|
|
("live.join", json!({ "path": "no\u{0}te.md" }), "invalid_value"),
|
|
("live.join", json!({ "path": "../escape.md" }), "invalid_value"),
|
|
("live.join", json!({ "path": "image.png" }), "invalid_value"),
|
|
("live.join", json!({ "path": "" }), "invalid_value"),
|
|
("live.join", json!({ "path": 7 }), "invalid_params"),
|
|
("live.selection", json!({ "path": "note.md", "anchor": "x", "head": 0 }), "invalid_params"),
|
|
];
|
|
for (id, params, expected) in cases {
|
|
let error = reg.run(id, &mut session, ¶ms).expect_err("hostile params");
|
|
assert_eq!(code(&error), expected, "{id} {params}");
|
|
}
|
|
// Three megabytes of text: a room never holds that much.
|
|
let big = "x".repeat(3 * 1024 * 1024);
|
|
let error = reg.run("live.edit", &mut session, &json!({ "path": "note.md", "text": big })).expect_err("too much text");
|
|
assert_eq!(code(&error), "invalid_value");
|
|
}
|
|
|
|
#[test]
|
|
fn dropping_the_session_with_an_open_room_is_quick() {
|
|
let stub = Stub::start("hello");
|
|
let vault = temp_vault("live-drop");
|
|
bind_folder(vault.path(), &stub.base, "vault-1", TOKEN, "device-1", None).expect("bind the vault");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
let reg = registry();
|
|
reg.run("live.join", &mut session, &json!({ "path": "note.md" })).expect("join");
|
|
|
|
let start = Instant::now();
|
|
drop(session);
|
|
let elapsed = start.elapsed();
|
|
assert!(elapsed < Duration::from_secs(2), "the session took {elapsed:?} to drop");
|
|
drop(vault);
|
|
}
|