#![cfg(feature = "offline-sync")]
use autumn_web::sync::{Op, SyncStore};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Note {
id: String,
title: String,
done: bool,
}
fn note(id: &str, title: &str) -> Note {
Note {
id: id.to_owned(),
title: title.to_owned(),
done: false,
}
}
#[test]
fn store_open_creates_schema_and_reopens() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("sync.db");
let first_device_id;
{
let store = SyncStore::open(&path).expect("open store");
store.put("notes", "n1", ¬e("n1", "hello")).expect("put");
first_device_id = store.device_id().expect("device_id");
assert!(!first_device_id.is_empty(), "device id should be generated");
}
let store = SyncStore::open(&path).expect("reopen store");
let fetched: Option<Note> = store.get("notes", "n1").expect("get");
assert_eq!(fetched, Some(note("n1", "hello")));
assert_eq!(
store.device_id().expect("device_id"),
first_device_id,
"device id must be stable across reopens"
);
assert_eq!(
store.pending_count().expect("pending_count"),
1,
"journal must survive reopen"
);
}
#[test]
fn put_get_list_roundtrip_typed_payloads() {
let dir = tempfile::tempdir().expect("tempdir");
let store = SyncStore::open(dir.path().join("sync.db")).expect("open store");
store
.put("notes", "b", ¬e("b", "second"))
.expect("put b");
store.put("notes", "a", ¬e("a", "first")).expect("put a");
store
.put("todos", "t1", ¬e("t1", "other collection"))
.expect("put todo");
let got: Option<Note> = store.get("notes", "a").expect("get");
assert_eq!(got, Some(note("a", "first")));
store
.put("notes", "a", ¬e("a", "first-edited"))
.expect("re-put a");
let got: Option<Note> = store.get("notes", "a").expect("get after edit");
assert_eq!(got, Some(note("a", "first-edited")));
let listed: Vec<(String, Note)> = store.list("notes").expect("list");
assert_eq!(
listed,
vec![
("a".to_owned(), note("a", "first-edited")),
("b".to_owned(), note("b", "second")),
]
);
let missing: Option<Note> = store.get("notes", "zzz").expect("get missing");
assert_eq!(missing, None);
let empty: Vec<(String, Note)> = store.list("nothing").expect("list empty");
assert!(empty.is_empty());
}
#[test]
fn delete_records_tombstone_and_pending_delete() {
let dir = tempfile::tempdir().expect("tempdir");
let store = SyncStore::open(dir.path().join("sync.db")).expect("open store");
store
.put("notes", "n1", ¬e("n1", "doomed"))
.expect("put");
store.delete("notes", "n1").expect("delete");
let got: Option<Note> = store.get("notes", "n1").expect("get");
assert_eq!(got, None, "deleted rows must read as absent");
let listed: Vec<(String, Note)> = store.list("notes").expect("list");
assert!(listed.is_empty(), "tombstones must not appear in list()");
let pending = store.pending_changes(10).expect("pending_changes");
let entry = pending
.iter()
.find(|c| c.collection == "notes" && c.pk == "n1")
.expect("a pending change for the deleted row");
assert_eq!(entry.op, Op::Delete);
assert_eq!(entry.payload, None, "deletes carry no payload");
assert!(!entry.change_id.is_empty());
}
#[test]
fn writes_journal_pending_changes_atomically() {
let dir = tempfile::tempdir().expect("tempdir");
let store = SyncStore::open(dir.path().join("sync.db")).expect("open store");
store.put("notes", "n1", ¬e("n1", "v1")).expect("put v1");
store.put("notes", "n1", ¬e("n1", "v2")).expect("put v2");
assert_eq!(store.pending_count().expect("count"), 1);
let pending = store.pending_changes(10).expect("pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].op, Op::Upsert);
assert_eq!(pending[0].collection, "notes");
assert_eq!(pending[0].pk, "n1");
assert_eq!(
pending[0].base_version, 0,
"a never-synced row is based on version 0"
);
let payload = pending[0].payload.as_ref().expect("upsert payload");
assert_eq!(
payload.get("title").and_then(|v| v.as_str()),
Some("v2"),
"the coalesced journal entry carries the latest payload"
);
store
.put("notes", "n2", ¬e("n2", "other"))
.expect("put n2");
assert_eq!(store.pending_count().expect("count"), 2);
store.delete("notes", "n1").expect("delete n1");
assert_eq!(store.pending_count().expect("count"), 2);
let pending = store.pending_changes(10).expect("pending");
let n1 = pending.iter().find(|c| c.pk == "n1").expect("n1 entry");
assert_eq!(n1.op, Op::Delete);
}
#[test]
fn concurrent_stores_on_one_file_never_fail_writes() {
const WRITES_PER_THREAD: usize = 500;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("sync.db");
let store1 = SyncStore::open(&path).expect("open 1");
let store2 = SyncStore::open(&path).expect("open 2");
let worker = |store: SyncStore, prefix: &'static str| {
std::thread::spawn(move || {
for i in 0..WRITES_PER_THREAD {
store
.put("notes", &format!("{prefix}-{i}"), &format!("payload {i}"))
.unwrap_or_else(|e| panic!("{prefix} write {i} failed: {e}"));
}
})
};
let t1 = worker(store1.clone(), "a");
let t2 = worker(store2, "b");
t1.join().expect("thread a");
t2.join().expect("thread b");
let listed: Vec<(String, String)> = store1.list("notes").expect("list");
assert_eq!(
listed.len(),
2 * WRITES_PER_THREAD,
"all concurrent writes from both connections must land"
);
assert_eq!(
store1.pending_count().expect("pending"),
2 * WRITES_PER_THREAD as u64,
"every write must have journaled its pending change"
);
}
#[test]
fn reads_reflect_local_writes_before_any_sync() {
let dir = tempfile::tempdir().expect("tempdir");
let store = SyncStore::open(dir.path().join("sync.db")).expect("open store");
store
.put("notes", "n1", ¬e("n1", "offline one"))
.expect("put");
store
.put("notes", "n2", ¬e("n2", "offline two"))
.expect("put");
let got: Option<Note> = store.get("notes", "n1").expect("get");
assert_eq!(got, Some(note("n1", "offline one")));
let listed: Vec<(String, Note)> = store.list("notes").expect("list");
assert_eq!(listed.len(), 2);
assert_eq!(store.pending_count().expect("count"), 2);
assert_eq!(
store.cursor().expect("cursor"),
0,
"nothing pulled yet — cursor stays at zero"
);
}