Files
Marcos Paulo d396f409b9
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: activity feed client, commands and the mention outbox
activity.list|unread|read and mention.candidates over the server's activity endpoints; a comment that mentions a member posts one event (never the body), kept in .notescraft/activity-outbox.json when the server is down and flushed on the next sync cycle; unread cursor in .notescraft/activity.json; unbound vaults answer not_bound (comments spec task 8). Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: notescraft-engine 838 tests pass, clippy -D warnings, xtask layers and tree clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:17:52 -03:00

300 lines
11 KiB
Rust

//! A fake sync server for the activity tests: it answers the activity routes
//! of the server and the members list.
//!
//! It speaks the same JSON as `notescraft-sync-server`
//! (`/v1/vaults/{vault}/activity`, `/v1/vaults/{vault}/activity/read` and
//! `/v1/vaults/{vault}/members`) and keeps the events it was told about, so a
//! test can read the feed and see the events a client posted.
#![allow(dead_code)] // shared test helpers: each test file uses only some of them
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{Value, json};
/// The token of the bound vault; it must never reach an answer or an error.
pub const TOKEN: &str = "secret-token";
/// The vault the tests bind to.
pub const VAULT: &str = "v-1";
/// How many events one page of the fake holds.
const MAX_PAGE: usize = 200;
/// One request the fake received.
#[derive(Debug, Clone)]
pub struct Request {
/// The HTTP method.
pub method: String,
/// The path of the request line.
pub path: String,
/// The `Authorization` header, when there was one.
pub auth: Option<String>,
/// The body of the request.
pub body: String,
}
/// One event of the feed of the fake.
#[derive(Debug, Clone)]
pub struct Event {
/// The id of the event.
pub id: u64,
/// The kind of the event.
pub kind: String,
/// The note the event is about.
pub note: Option<String>,
/// The thread the event is about.
pub thread_id: Option<String>,
/// The members the event mentions.
pub mentions: Vec<String>,
/// True once the member read the event.
pub read: bool,
}
/// What the fake remembers between requests.
#[derive(Default)]
struct State {
/// The events of the feed.
events: Vec<Event>,
/// The members of the vault.
members: Vec<Value>,
/// How many ids were handed out.
next: u64,
}
/// A fake sync server: it answers the activity routes.
pub struct Fake {
/// The base URL of the fake.
pub base: String,
/// Every request the fake received.
seen: Arc<Mutex<Vec<Request>>>,
/// What the fake remembers.
state: Arc<Mutex<State>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Fake {
/// Starts a fake on an ephemeral port.
pub fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the fake");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
let seen: Arc<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
let state: Arc<Mutex<State>> = Arc::new(Mutex::new(State::default()));
let stop = Arc::new(AtomicBool::new(false));
let thread_seen = Arc::clone(&seen);
let thread_state = Arc::clone(&state);
let thread_stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(false);
let Ok(mut writer) = stream.try_clone() else { continue };
let mut reader = BufReader::new(stream);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(30)));
let seen = Arc::clone(&thread_seen);
let state = Arc::clone(&thread_state);
std::thread::spawn(move || {
while let Some(request) = read_request(&mut reader) {
seen.lock().expect("seen").push(request.clone());
let (status, body) = answer(&state, &request);
write_answer(&mut writer, status, &body);
}
});
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), seen, state, stop }
}
/// Every request the fake received.
pub fn requests(&self) -> Vec<Request> {
self.seen.lock().expect("seen").clone()
}
/// The events of the feed of the fake, oldest first.
pub fn events(&self) -> Vec<Event> {
self.state.lock().expect("state").events.clone()
}
/// Puts one event in the feed, as another member would have posted it.
pub fn seed(&self, kind: &str, note: &str, mentions: &[&str]) -> u64 {
let mut state = self.state.lock().expect("state");
state.next += 1;
let id = state.next;
state.events.push(Event {
id,
kind: kind.to_owned(),
note: Some(note.to_owned()),
thread_id: Some(format!("c-{id}")),
mentions: mentions.iter().map(|name| (*name).to_owned()).collect(),
read: false,
});
id
}
/// Puts the members of the vault in the list of the fake.
pub fn set_members(&self, members: Vec<Value>) {
self.state.lock().expect("state").members = members;
}
}
impl Drop for Fake {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// The answer of the fake for one request: `(status, body)`.
fn answer(state: &Arc<Mutex<State>>, request: &Request) -> (u16, String) {
let method = request.method.as_str();
let path = request.path.as_str();
let feed = format!("/v1/vaults/{VAULT}/activity");
let read = format!("/v1/vaults/{VAULT}/activity/read");
let members = format!("/v1/vaults/{VAULT}/members");
let mut state = state.lock().expect("state");
if method == "POST" && path == feed {
return post_event(&mut state, request);
}
if method == "GET" && path.starts_with(&feed) {
return list_events(&state, path);
}
if method == "POST" && path == read {
return mark_read(&mut state, request);
}
if method == "GET" && path == members {
return (200, Value::Array(state.members.clone()).to_string());
}
not_found()
}
/// A new event of the feed of the fake.
fn post_event(state: &mut State, request: &Request) -> (u16, String) {
let body: Value = serde_json::from_str(&request.body).unwrap_or(Value::Null);
let kind = body.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned();
let note = body.get("note").and_then(Value::as_str).map(str::to_owned);
let thread_id = body.get("thread_id").and_then(Value::as_str).map(str::to_owned);
let mentions: Vec<String> =
body.get("mentions").and_then(Value::as_array).map(|list| list.iter().filter_map(Value::as_str).map(str::to_owned).collect()).unwrap_or_default();
state.next += 1;
let id = state.next;
state.events.push(Event { id, kind: kind.clone(), note, thread_id, mentions: mentions.clone(), read: false });
let head = state.next;
(200, json!({ "id": id, "kind": kind, "head": head, "recipients": mentions.len() }).to_string())
}
/// The page of the feed of the fake.
fn list_events(state: &State, path: &str) -> (u16, String) {
let since = query_number(path, "since").unwrap_or(0);
let cap = u64::try_from(MAX_PAGE).unwrap_or(u64::MAX);
let limit = usize::try_from(query_number(path, "limit").unwrap_or(50).min(cap)).unwrap_or(MAX_PAGE);
let visible: Vec<&Event> = state.events.iter().filter(|event| event.id > since).collect();
let more = visible.len() > limit;
let rows: Vec<Value> = visible.iter().take(limit).map(|event| event_json(event)).collect();
let unread = state.events.iter().filter(|event| !event.read).count();
(200, json!({ "events": rows, "head": state.next, "more": more, "unread": unread }).to_string())
}
/// The events of the fake as the rows of the feed.
fn event_json(event: &Event) -> Value {
json!({
"id": event.id,
"ts_ms": 10,
"kind": event.kind,
"actor": "u-ana",
"actor_name": "Ana",
"note": event.note,
"thread_id": event.thread_id,
"line": Value::Null,
"read": event.read,
})
}
/// Marks the events of the fake read.
fn mark_read(state: &mut State, request: &Request) -> (u16, String) {
let body: Value = serde_json::from_str(&request.body).unwrap_or(Value::Null);
let ids = body.get("ids").and_then(Value::as_array).map(|list| list.iter().filter_map(Value::as_u64).collect::<Vec<u64>>()).unwrap_or_default();
for event in &mut state.events {
if ids.is_empty() || ids.contains(&event.id) {
event.read = true;
}
}
let unread = state.events.iter().filter(|event| !event.read).count();
(200, json!({ "unread": unread }).to_string())
}
/// One number of the query of a request.
fn query_number(path: &str, name: &str) -> Option<u64> {
let query = path.split_once('?').map(|(_, query)| query).unwrap_or_default();
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then(|| value.parse().ok())?
})
}
/// The answer of a route the fake does not know.
fn not_found() -> (u16, String) {
(404, "{\"error\":\"no such route\"}".to_owned())
}
/// Reads one request of a connection.
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let method = parts.next()?.to_owned();
let path = parts.next()?.to_owned();
let mut auth = None;
let mut length = 0_usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let trimmed = header.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((name, value)) = trimmed.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim().to_owned();
if name == "content-length" {
length = value.parse().unwrap_or(0);
}
if name == "authorization" {
auth = Some(value.clone());
}
}
}
let mut body = vec![0_u8; length];
if length > 0 {
reader.read_exact(&mut body).ok()?;
}
Some(Request { method, path, auth, body: String::from_utf8_lossy(&body).into_owned() })
}
/// Writes one answer of the fake.
fn write_answer(stream: &mut TcpStream, status: u16, body: &str) {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
_ => "Status",
};
let head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len());
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}