use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use mbr_core::config::Config;
use mbr_core::vault::{Availability, MemVault, Vault, VaultPath};
use mbr_server::{EmbeddedStorage, Server, ServerConfig};
use tower::ServiceExt;
const NOTE: &str =
"---\ntitle: Kumquat Note\ntags: [fruit]\n---\n# Hello\n\nSome searchable kumquat text.\n";
fn vault() -> Arc<MemVault> {
let video: Vec<u8> = (0..=255u8).cycle().take(600_000).collect();
Arc::new(
MemVault::new()
.with_file("note.md", NOTE)
.with_file("cloud.md", "# Cloud\n\nkumquat in the cloud\n")
.with_file("docs/guide.md", "# Guide\n\nSee [the note](../note.md).\n")
.with_file("media/clip.mp4", video)
.with_file("media/far.png", "png"),
)
}
fn config(vault: &Arc<MemVault>, snapshot: Option<mbr_core::repo::RepoSnapshot>) -> ServerConfig {
let mut config = ServerConfig::from(&Config::default());
config.base_dir = vault.root().to_path_buf();
config.oembed_timeout_ms = 0;
config.embedded = Some(EmbeddedStorage {
vault: Arc::clone(vault) as Arc<dyn Vault>,
snapshot,
});
config
}
async fn server(vault: &Arc<MemVault>) -> Server {
let server = Server::init(config(vault, None)).expect("init");
server.repo().wait_for_media().await;
server
}
async fn send(
server: &Server,
request: Request<Body>,
) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
let response = server.router.clone().oneshot(request).await.unwrap();
let (parts, body) = response.into_parts();
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
(parts.status, parts.headers, bytes.to_vec())
}
fn get(path: &str) -> Request<Body> {
Request::get(path).body(Body::empty()).unwrap()
}
fn get_range(path: &str, range: &str) -> Request<Body> {
Request::get(path)
.header(header::RANGE, range)
.body(Body::empty())
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn pages_render_from_a_vault_without_local_paths() {
let vault = vault();
let server = server(&vault).await;
let (status, headers, body) = send(&server, get("/note/")).await;
assert_eq!(status, StatusCode::OK);
assert!(
headers[header::CONTENT_TYPE]
.to_str()
.unwrap()
.contains("text/html")
);
let html = String::from_utf8(body).unwrap();
assert!(html.contains("kumquat"), "{html}");
let (status, _, body) = send(&server, get("/docs/guide/")).await;
assert_eq!(status, StatusCode::OK);
assert!(String::from_utf8(body).unwrap().contains("Guide"));
let (status, _, body) = send(&server, get("/.mbr/site.json")).await;
assert_eq!(status, StatusCode::OK);
let site: serde_json::Value = serde_json::from_slice(&body).unwrap();
let urls: Vec<&str> = site["markdown_files"]
.as_array()
.unwrap()
.iter()
.filter_map(|m| m["url_path"].as_str())
.collect();
assert!(
urls.contains(&"/note/") && urls.contains(&"/docs/guide/"),
"{urls:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn assets_stream_through_the_vault_with_ranges() {
let vault = vault();
let server = server(&vault).await;
let expected = vault.contents("media/clip.mp4").unwrap();
let (status, headers, body) = send(&server, get("/media/clip.mp4")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::ACCEPT_RANGES], "bytes");
assert_eq!(headers[header::CONTENT_TYPE], "video/mp4");
assert_eq!(headers[header::CONTENT_LENGTH], expected.len().to_string());
assert!(
!headers.contains_key(header::CONTENT_ENCODING),
"video is never compressed"
);
assert_eq!(body, expected);
let (status, headers, body) = send(&server, get_range("/media/clip.mp4", "bytes=0-99")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(
headers[header::CONTENT_RANGE],
format!("bytes 0-99/{}", expected.len())
);
assert_eq!(body, expected[..100]);
let (status, _, body) = send(&server, get_range("/media/clip.mp4", "bytes=500000-")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, expected[500_000..]);
let (status, _, body) = send(&server, get_range("/media/clip.mp4", "bytes=-50")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, expected[expected.len() - 50..]);
let (status, headers, _) =
send(&server, get_range("/media/clip.mp4", "bytes=999999999-")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
headers[header::CONTENT_RANGE],
format!("bytes */{}", expected.len())
);
let (status, _, _) = send(&server, get_range("/media/clip.mp4", "bytes=nonsense")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
let (status, _, _) = send(&server, get("/media/missing.mp4")).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test(flavor = "multi_thread")]
async fn not_downloaded_files_get_a_retry_answer_and_a_download_request() {
let vault = vault();
vault.set_availability("cloud.md", Availability::NotDownloaded);
vault.set_availability("media/far.png", Availability::NotDownloaded);
let server = server(&vault).await;
let (_, _, body) = send(&server, get("/.mbr/site.json")).await;
assert!(String::from_utf8(body).unwrap().contains("/cloud/"));
let search = Request::post("/.mbr/search")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"q":"kumquat","scope":"content"}"#))
.unwrap();
let (status, _, body) = send(&server, search).await;
assert_eq!(status, StatusCode::OK);
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["not_downloaded"], 1, "{json}");
let (status, headers, body) = send(&server, get("/cloud/")).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(headers[header::RETRY_AFTER], "2");
assert_eq!(headers["refresh"], "2");
let page = String::from_utf8(body).unwrap();
assert!(
page.contains("<title>cloud</title>"),
"the note's name: {page}"
);
assert!(
page.contains("<strong>cloud</strong>"),
"the header title: {page}"
);
assert!(page.contains("class=\"mbr-downloading\""), "{page}");
assert!(page.contains("<progress"), "{page}");
assert!(page.contains("mbr-downloading-name\">cloud<"), "{page}");
assert!(
page.contains("class=\"mbr-downloading-title mbr-downloading-offline\""),
"{page}"
);
assert!(page.contains("back online"), "{page}");
assert!(!page.contains("<h1>503</h1>"), "{page}");
let (status, headers, _) = send(&server, get("/media/far.png")).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(headers[header::RETRY_AFTER], "2");
let requested: Vec<String> = vault
.download_requests()
.iter()
.map(|p| p.to_string())
.collect();
assert!(requested.contains(&"cloud.md".to_string()), "{requested:?}");
assert!(
requested.contains(&"media/far.png".to_string()),
"{requested:?}"
);
let (status, _, _) = send(&server, get("/cloud/")).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test(flavor = "multi_thread")]
async fn refresh_applies_what_changed_in_the_vault() {
let vault = vault();
let server = server(&vault).await;
assert!(server.refresh().await.is_empty(), "nothing changed yet");
vault.insert_file("fresh.md", "---\ntitle: Fresh Arrival\n---\n");
vault.insert_file("note.md", "---\ntitle: Renamed Title\n---\n# Hello\n");
vault.set_modified(
"note.md",
SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000),
);
vault
.remove_file(&VaultPath::new("docs/guide.md").unwrap())
.unwrap();
let diff = server.refresh().await;
assert_eq!(
(diff.created.len(), diff.modified.len(), diff.deleted.len()),
(1, 1, 1),
"{diff:?}"
);
let (_, _, body) = send(&server, get("/.mbr/site.json")).await;
let site = String::from_utf8(body).unwrap();
assert!(site.contains("Fresh Arrival"), "created note indexed");
assert!(
site.contains("Renamed Title"),
"modified frontmatter re-read"
);
assert!(!site.contains("/docs/guide/"), "deleted note dropped");
assert!(server.refresh().await.is_empty(), "applied once");
}
#[tokio::test(flavor = "multi_thread")]
async fn folders_whose_listing_is_not_downloaded_are_requested_and_indexed_on_arrival() {
let vault = vault();
vault.insert_file("trips/rome.md", "---\ntitle: Roman Holiday\n---\n");
vault.set_dir_availability("trips", Availability::NotDownloaded);
let server = server(&vault).await;
let trips = VaultPath::new("trips").unwrap();
assert!(vault.download_requests().contains(&trips), "the scan asked");
assert_eq!(server.repo().not_downloaded_folder_count(), 1);
let (_, _, body) = send(&server, get("/.mbr/site.json")).await;
assert!(!String::from_utf8(body).unwrap().contains("Roman Holiday"));
let diff = server.refresh().await;
assert!(diff.is_empty(), "{diff:?}");
assert_eq!(diff.not_downloaded_folders.len(), 1);
vault.set_dir_availability("trips", Availability::Local);
let diff = server.refresh().await;
assert_eq!(diff.created.len(), 1, "{diff:?}");
assert!(diff.not_downloaded_folders.is_empty());
assert_eq!(server.repo().not_downloaded_folder_count(), 0);
let (_, _, body) = send(&server, get("/.mbr/site.json")).await;
assert!(String::from_utf8(body).unwrap().contains("Roman Holiday"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_snapshot_restores_without_reading_and_a_stale_one_is_ignored() {
let vault = vault();
let first = server(&vault).await;
assert_eq!(first.restored_files(), None);
let snapshot = first.repo().snapshot();
let (_, _, scanned) = send(&first, get("/.mbr/site.json")).await;
drop(first);
let restored = Server::init(config(&vault, Some(snapshot.clone()))).unwrap();
assert_eq!(restored.restored_files(), Some(snapshot.len()));
restored.repo().wait_for_scan().await;
let (_, _, body) = send(&restored, get("/.mbr/site.json")).await;
assert_eq!(body, scanned, "same site.json as the scan that took it");
assert!(restored.refresh().await.is_empty());
let mut other = config(&vault, Some(snapshot));
other.markdown_extensions.push("markdown".into());
let rescanned = Server::init(other).unwrap();
assert_eq!(rescanned.restored_files(), None);
rescanned.repo().wait_for_scan().await;
let (_, _, body) = send(&rescanned, get("/.mbr/site.json")).await;
assert!(String::from_utf8(body).unwrap().contains("/note/"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_large_refresh_is_applied_file_by_file_and_keeps_evicted_metadata() {
let vault = vault();
let server = server(&vault).await;
vault.set_availability("note.md", Availability::NotDownloaded);
for i in 0..80 {
vault.insert_file(
&format!("batch/n{i:02}.md"),
format!("---\ntitle: Batch {i}\n---\n"),
);
}
vault.insert_file("media/new.png", "png");
let diff = server.refresh().await;
assert_eq!(diff.created.len(), 81, "{diff:?}");
assert_eq!(diff.evicted.len(), 1);
let (_, _, body) = send(&server, get("/.mbr/site.json")).await;
let site = String::from_utf8(body).unwrap();
assert!(site.contains("Batch 79"));
assert!(
site.contains("Kumquat Note"),
"a rescan would have dropped the evicted note's title"
);
let (status, _, _) = send(&server, get("/media/new.png")).await;
assert_eq!(status, StatusCode::OK);
assert!(server.refresh().await.is_empty());
}
#[derive(Debug)]
struct FlakyStatVault {
inner: Arc<MemVault>,
target: VaultPath,
failures: std::sync::Mutex<usize>,
}
impl Vault for FlakyStatVault {
fn root(&self) -> &std::path::Path {
self.inner.root()
}
fn stat(
&self,
path: &VaultPath,
) -> Result<Option<mbr_core::vault::Entry>, mbr_core::vault::VaultError> {
if *path == self.target {
let mut left = self.failures.lock().unwrap();
if *left > 0 {
*left -= 1;
return Err(mbr_core::vault::VaultError::PermissionDenied {
path: path.to_string(),
});
}
}
self.inner.stat(path)
}
fn list_dir(
&self,
dir: &VaultPath,
) -> Result<Vec<mbr_core::vault::Entry>, mbr_core::vault::VaultError> {
self.inner.list_dir(dir)
}
fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, mbr_core::vault::VaultError> {
self.inner.canonicalize(path)
}
fn read(&self, path: &VaultPath) -> Result<Vec<u8>, mbr_core::vault::VaultError> {
self.inner.read(path)
}
fn read_prefix(
&self,
path: &VaultPath,
max_len: usize,
) -> Result<Vec<u8>, mbr_core::vault::VaultError> {
self.inner.read_prefix(path, max_len)
}
fn write_atomic(
&self,
path: &VaultPath,
bytes: &[u8],
) -> Result<(), mbr_core::vault::VaultError> {
self.inner.write_atomic(path, bytes)
}
fn create_dir_all(&self, path: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.inner.create_dir_all(path)
}
fn remove_file(&self, path: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.inner.remove_file(path)
}
fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.inner.rename(from, to)
}
}
#[tokio::test(flavor = "multi_thread")]
async fn an_edit_survives_a_momentary_stat_failure_on_its_note() {
let inner = Arc::new(MemVault::new().with_file("notes.md", "# Notes\n\n- [ ] one\n"));
let mut config = ServerConfig::from(&Config::default());
config.base_dir = inner.root().to_path_buf();
config.oembed_timeout_ms = 0;
config.edit_enabled = true;
let flaky = Arc::new(FlakyStatVault {
inner: Arc::clone(&inner),
target: VaultPath::new("notes.md").unwrap(),
failures: std::sync::Mutex::new(0),
});
config.embedded = Some(EmbeddedStorage {
vault: Arc::clone(&flaky) as Arc<dyn Vault>,
snapshot: None,
});
let server = Server::init(config).expect("init");
server.repo().wait_for_media().await;
*flaky.failures.lock().unwrap() = 2;
let mut toggle = Request::post("/.mbr/task")
.header(header::HOST, "localhost")
.header(header::CONTENT_TYPE, "application/json")
.header("X-MBR-Edit", "1")
.body(Body::from(
r#"{"path":"notes.md","line":3,"expected":"- [ ] one","to":"done"}"#,
))
.unwrap();
toggle
.extensions_mut()
.insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
0,
))));
let (status, _, body) = send(&server, toggle).await;
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let text = String::from_utf8(inner.contents("notes.md").unwrap()).unwrap();
assert!(text.contains("- [x] one"), "{text}");
}
#[derive(Debug)]
struct CloudyVault {
local: mbr_core::vault::LocalVault,
pending: std::sync::Mutex<std::collections::HashSet<VaultPath>>,
arrive_on_request: bool,
downloads: std::sync::Mutex<Vec<VaultPath>>,
}
impl Vault for CloudyVault {
fn root(&self) -> &std::path::Path {
self.local.root()
}
fn stat(
&self,
path: &VaultPath,
) -> Result<Option<mbr_core::vault::Entry>, mbr_core::vault::VaultError> {
self.local.stat(path)
}
fn list_dir(
&self,
dir: &VaultPath,
) -> Result<Vec<mbr_core::vault::Entry>, mbr_core::vault::VaultError> {
self.local.list_dir(dir)
}
fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, mbr_core::vault::VaultError> {
self.local.canonicalize(path)
}
fn read(&self, path: &VaultPath) -> Result<Vec<u8>, mbr_core::vault::VaultError> {
self.local.read(path)
}
fn read_prefix(
&self,
path: &VaultPath,
max_len: usize,
) -> Result<Vec<u8>, mbr_core::vault::VaultError> {
self.local.read_prefix(path, max_len)
}
fn availability(&self, path: &VaultPath) -> Availability {
if self.pending.lock().unwrap().contains(path) {
Availability::NotDownloaded
} else {
self.local.availability(path)
}
}
fn write_atomic(
&self,
path: &VaultPath,
bytes: &[u8],
) -> Result<(), mbr_core::vault::VaultError> {
self.local.write_atomic(path, bytes)
}
fn create_dir_all(&self, path: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.local.create_dir_all(path)
}
fn remove_file(&self, path: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.local.remove_file(path)
}
fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), mbr_core::vault::VaultError> {
self.local.rename(from, to)
}
fn request_download(&self, paths: &[VaultPath]) {
self.downloads.lock().unwrap().extend_from_slice(paths);
if self.arrive_on_request {
let mut pending = self.pending.lock().unwrap();
paths.iter().for_each(|p| {
pending.remove(p);
});
}
}
fn local_path(&self, path: &VaultPath) -> Option<std::path::PathBuf> {
self.local.local_path(path)
}
}
async fn mbr_asset_server(
vault_for: impl FnOnce(&std::path::Path) -> Arc<dyn Vault>,
) -> (tempfile::TempDir, Server) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".mbr")).unwrap();
std::fs::write(dir.path().join(".mbr/user.css"), "body { color: teal }").unwrap();
std::fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
let root = dir.path().canonicalize().unwrap();
let mut config = ServerConfig::from(&Config::default());
config.base_dir = root.clone();
config.oembed_timeout_ms = 0;
config.embedded = Some(EmbeddedStorage {
vault: vault_for(&root),
snapshot: None,
});
let server = Server::init(config).expect("init");
server.repo().wait_for_media().await;
(dir, server)
}
fn cloudy(root: &std::path::Path, arrive_on_request: bool) -> Arc<CloudyVault> {
Arc::new(CloudyVault {
local: mbr_core::vault::LocalVault::new(root),
pending: std::sync::Mutex::new(
[VaultPath::new(".mbr/user.css").unwrap()]
.into_iter()
.collect(),
),
arrive_on_request,
downloads: std::sync::Mutex::new(Vec::new()),
})
}
#[tokio::test(flavor = "multi_thread")]
async fn a_not_downloaded_mbr_asset_is_requested_and_answers_retry() {
let vault = std::sync::OnceLock::new();
let (_dir, server) = mbr_asset_server(|root| {
let cloudy = cloudy(root, false);
vault.set(Arc::clone(&cloudy)).unwrap();
cloudy
})
.await;
let started = std::time::Instant::now();
let (status, headers, _) = send(&server, get("/.mbr/user.css")).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(headers[header::RETRY_AFTER], "2");
assert!(
started.elapsed() >= Duration::from_millis(2500),
"waited for the download first: {:?}",
started.elapsed()
);
assert_eq!(
*vault.get().unwrap().downloads.lock().unwrap(),
vec![VaultPath::new(".mbr/user.css").unwrap()],
"asked for exactly once"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_not_downloaded_mbr_asset_that_arrives_in_time_is_served() {
let (_dir, server) = mbr_asset_server(|root| cloudy(root, true)).await;
let (status, _, body) = send(&server, get("/.mbr/user.css")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"body { color: teal }");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_mbr_asset_is_served_without_waiting() {
let (_dir, server) =
mbr_asset_server(|root| Arc::new(mbr_core::vault::LocalVault::new(root)) as Arc<dyn Vault>)
.await;
let started = std::time::Instant::now();
let (status, _, body) = send(&server, get("/.mbr/user.css")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"body { color: teal }");
assert!(started.elapsed() < Duration::from_secs(1));
}
const EDITABLE: &str = "# Notes\n\n- [ ] one\n\nQ1?\n: A1\n";
async fn editing_server(vault: &Arc<MemVault>) -> Server {
let mut config = config(vault, None);
config.edit_enabled = true;
let server = Server::init(config).expect("init");
server.repo().wait_for_media().await;
server
}
fn edit_request(method: &str, uri: &str, json: Option<String>) -> Request<Body> {
let builder = Request::builder()
.method(method)
.uri(uri)
.header(header::HOST, "localhost")
.header("X-MBR-Edit", "1");
let mut request = match json {
Some(json) => builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json)),
None => builder.body(Body::empty()),
}
.unwrap();
request
.extensions_mut()
.insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
0,
))));
request
}
fn every_edit_read(base_hash: &str) -> Vec<(&'static str, Request<Body>)> {
let at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string();
vec![
("raw", edit_request("GET", "/.mbr/raw/notes.md", None)),
(
"edit",
edit_request(
"POST",
"/.mbr/edit/notes.md",
Some(
serde_json::json!({"content": "# Replaced\n", "base_hash": base_hash})
.to_string(),
),
),
),
(
"task",
edit_request(
"POST",
"/.mbr/task",
Some(
serde_json::json!({"path": "notes.md", "line": 3, "expected": "- [ ] one", "to": "done"})
.to_string(),
),
),
),
(
"flashcard-review",
edit_request(
"POST",
"/.mbr/flashcard-review",
Some(
serde_json::json!({"path": "notes.md", "line": 5, "expected": "Q1?", "rating": "good", "at": at})
.to_string(),
),
),
),
]
}
#[tokio::test(flavor = "multi_thread")]
async fn edit_endpoints_answer_retry_for_a_note_that_is_not_downloaded() {
let vault = Arc::new(MemVault::new().with_file("notes.md", EDITABLE));
let server = editing_server(&vault).await;
vault.set_availability("notes.md", Availability::NotDownloaded);
vault.hold_downloads(true);
let base_hash = mbr_core::edit_auth::content_hash(EDITABLE.as_bytes());
let started = std::time::Instant::now();
let answers = futures::future::join_all(every_edit_read(&base_hash).into_iter().map(
|(name, request)| {
let server = &server;
async move { (name, send(server, request).await) }
},
))
.await;
for (name, (status, headers, body)) in answers {
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"{name}: {}",
String::from_utf8_lossy(&body)
);
assert_eq!(headers[header::RETRY_AFTER], "2", "{name}");
}
assert!(
started.elapsed() >= Duration::from_millis(2500),
"waited for the download first: {:?}",
started.elapsed()
);
assert!(
vault
.download_requests()
.contains(&VaultPath::new("notes.md").unwrap())
);
assert_eq!(vault.contents("notes.md").unwrap(), EDITABLE.as_bytes());
}
#[tokio::test(flavor = "multi_thread")]
async fn edit_endpoints_read_a_note_that_arrives_on_request() {
let base_hash = mbr_core::edit_auth::content_hash(EDITABLE.as_bytes());
for (name, request) in every_edit_read(&base_hash) {
let vault = Arc::new(MemVault::new().with_file("notes.md", EDITABLE));
let server = editing_server(&vault).await;
vault.set_availability("notes.md", Availability::NotDownloaded);
let (status, _, body) = send(&server, request).await;
assert_eq!(
status,
StatusCode::OK,
"{name}: {}",
String::from_utf8_lossy(&body)
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_task_changed_on_another_device_downloads_then_conflicts() {
let vault = Arc::new(MemVault::new().with_file("notes.md", EDITABLE));
let server = editing_server(&vault).await;
let changed = EDITABLE.replace("- [ ] one", "- [ ] one, edited on the Mac");
vault.insert_file("notes.md", &changed);
vault.set_availability("notes.md", Availability::NotDownloaded);
let toggle = every_edit_read("")
.into_iter()
.find(|(name, _)| *name == "task")
.unwrap()
.1;
let (status, _, _) = send(&server, toggle).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(vault.contents("notes.md").unwrap(), changed.as_bytes());
}