use tempfile::tempdir; use better_twttr::{ capture::CaptureSession, db::Store, model::{CaptureInput, SaveDraftInput}, }; fn input(id: &str, revision: i64, text: &str) -> SaveDraftInput { SaveDraftInput { id: id.into(), account_id: "local".into(), kind: "post".into(), title: "Test draft".into(), text: text.into(), context_url: String::new(), expected_revision: revision, source: "local".into(), } } fn capture(sequence: u64, text: &str) -> CaptureInput { CaptureInput { composer_id: "composer-1".into(), sequence, text: text.into(), context_url: "https://x.com/compose/post".into(), kind: "post".into(), } } #[test] fn committed_drafts_and_revisions_survive_reopen() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); { let mut store = Store::open(&path).unwrap(); assert_eq!( store .save_draft(&input("first", 0, "Türkçe 🐟")) .unwrap() .revision, 1 ); assert_eq!( store .save_draft(&input("first", 1, "A second revision")) .unwrap() .revision, 2 ); } let mut reopened = Store::open(&path).unwrap(); assert_eq!( reopened.get_draft("first").unwrap().unwrap().text, "A second revision" ); let history = reopened.revisions("first").unwrap(); assert_eq!(history.len(), 2); assert_eq!(history[1].text, "Türkçe 🐟"); let exported = reopened.export().unwrap(); assert_eq!(exported.version, 1); assert_eq!(exported.drafts.len(), 1); assert_eq!(exported.revisions.len(), 2); assert!(exported.observations.is_empty()); let connection = rusqlite::Connection::open(&path).unwrap(); let mode: String = connection .pragma_query_value(None, "journal_mode", |row| row.get(0)) .unwrap(); assert_eq!(mode, "wal"); } #[test] #[ignore = "subprocess fixture, invoked by abrupt_exit_keeps_acknowledged_revision"] fn abrupt_exit_fixture() { let path = std::env::var_os("X_WORKSPACE_CRASH_FIXTURE_PATH") .expect("parent provides isolated fixture path"); let mut store = Store::open(path).unwrap(); let ack = store .save_draft(&input("abrupt", 0, "acknowledged before abrupt exit 🐟")) .unwrap(); assert_eq!(ack.revision, 1); // Exit without unwinding or dropping SQLite's Connection: no orderly close/checkpoint. std::process::exit(37); } #[test] fn abrupt_exit_keeps_acknowledged_revision() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); let status = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--ignored", "--exact", "abrupt_exit_fixture"]) .env("X_WORKSPACE_CRASH_FIXTURE_PATH", &path) .status() .unwrap(); assert_eq!(status.code(), Some(37)); let store = Store::open(&path).unwrap(); let draft = store.get_draft("abrupt").unwrap().unwrap(); assert_eq!(draft.revision, 1); assert_eq!(draft.text, "acknowledged before abrupt exit 🐟"); assert_eq!(store.revisions("abrupt").unwrap().len(), 1); } #[test] fn concurrent_writers_cannot_silently_overwrite_each_other() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); Store::open(&path) .unwrap() .save_draft(&input("same", 0, "original")) .unwrap(); let first = path.clone(); let second = path.clone(); let a = std::thread::spawn(move || { Store::open(first) .unwrap() .save_draft(&input("same", 1, "writer A")) }); let b = std::thread::spawn(move || { Store::open(second) .unwrap() .save_draft(&input("same", 1, "writer B")) }); let results = [a.join().unwrap(), b.join().unwrap()]; assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); assert!(results .iter() .find_map(|r| r.as_ref().err()) .unwrap() .starts_with("CONFLICT:")); assert_eq!( Store::open(path).unwrap().revisions("same").unwrap().len(), 2 ); } #[test] fn revision_insertion_failure_rolls_back_current_draft() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); let mut store = Store::open(&path).unwrap(); store.save_draft(&input("atomic", 0, "original")).unwrap(); let connection = rusqlite::Connection::open(&path).unwrap(); connection.execute_batch("CREATE TRIGGER fail_snapshot BEFORE INSERT ON revisions BEGIN SELECT RAISE(ABORT,'simulated revision failure'); END;").unwrap(); assert!(store .save_draft(&input("atomic", 1, "must roll back")) .is_err()); let draft = store.get_draft("atomic").unwrap().unwrap(); assert_eq!(draft.text, "original"); assert_eq!(draft.revision, 1); assert_eq!(store.revisions("atomic").unwrap().len(), 1); } #[test] fn invalid_input_and_account_switch_leave_original_untouched() { let mut store = Store::open(":memory:").unwrap(); store.save_draft(&input("draft", 0, "original")).unwrap(); let mut invalid = input("draft", 1, "replacement"); invalid.account_id = "someone-else".into(); assert!(store .save_draft(&invalid) .unwrap_err() .starts_with("FORBIDDEN:")); invalid.account_id = "local".into(); invalid.context_url = "javascript:alert(1)".into(); assert!(store .save_draft(&invalid) .unwrap_err() .starts_with("VALIDATION:")); invalid.context_url.clear(); invalid.text = "x".repeat(262_145); assert!(store.save_draft(&invalid).is_err()); assert_eq!(store.get_draft("draft").unwrap().unwrap().text, "original"); assert_eq!(store.revisions("draft").unwrap().len(), 1); } #[test] fn archive_restore_is_revisioned_and_never_deletes_text() { let mut store = Store::open(":memory:").unwrap(); store.save_draft(&input("draft", 0, "keep me")).unwrap(); assert_eq!( store.set_status("draft", 1, "archived").unwrap().revision, 2 ); assert!(store .set_status("draft", 1, "active") .unwrap_err() .starts_with("CONFLICT:")); let restored = store.set_status("draft", 2, "active").unwrap(); assert_eq!(restored.text, "keep me"); assert_eq!(restored.revision, 3); assert_eq!(store.revisions("draft").unwrap().len(), 3); } #[test] fn empty_capture_is_preserved_as_a_revision() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); session.write(&mut store, capture(1, "original")).unwrap(); let ack = session.write(&mut store, capture(2, "")).unwrap(); assert_eq!(ack.revision, 2); let draft = &store.list_drafts().unwrap()[0]; assert_eq!(draft.account_id, "unassigned-x"); assert_eq!(draft.source, "capture"); assert_eq!(draft.text, ""); assert_eq!(store.revisions(&draft.id).unwrap()[1].text, "original"); } #[test] fn capture_retries_are_idempotent_and_stale_sequences_cannot_overwrite() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); let ack = session.write(&mut store, capture(1, "original")).unwrap(); assert_eq!( session.write(&mut store, capture(1, "original")).unwrap(), ack ); assert!(session .write(&mut store, capture(1, "changed")) .unwrap_err() .starts_with("CONFLICT:")); session.write(&mut store, capture(3, "newer")).unwrap(); assert!(session.write(&mut store, capture(2, "older")).is_err()); let draft = &store.list_drafts().unwrap()[0]; assert_eq!(draft.text, "newer"); assert_eq!(store.revisions(&draft.id).unwrap().len(), 2); } #[test] fn navigation_rotates_server_mapping_without_touching_previous_draft() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); session .write(&mut store, capture(1, "first account, identity unknown")) .unwrap(); let before = store.list_drafts().unwrap()[0].clone(); session.reset(); session .write(&mut store, capture(1, "second page, identity unknown")) .unwrap(); assert_eq!(store.list_drafts().unwrap().len(), 2); assert_eq!(store.get_draft(&before.id).unwrap().unwrap(), before); } #[test] fn remote_cannot_choose_a_local_draft_id_or_account() { let mut store = Store::open(":memory:").unwrap(); store .save_draft(&input("composer-1", 0, "local secret")) .unwrap(); let mut session = CaptureSession::default(); session .write(&mut store, capture(1, "remote text")) .unwrap(); assert_eq!( store.get_draft("composer-1").unwrap().unwrap().text, "local secret" ); let payload = r#"{"composerId":"test","sequence":1,"text":"a","contextUrl":"https://x.com/home","kind":"post","accountId":"local","id":"composer-1"}"#; assert!(serde_json::from_str::(payload).is_err()); } #[test] fn capture_after_local_edit_forks_and_preserves_local_edit() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); session.write(&mut store, capture(1, "captured")).unwrap(); let draft = store.list_drafts().unwrap()[0].clone(); let mut edited = input(&draft.id, 1, "user edited locally"); edited.account_id = "unassigned-x".into(); store.save_draft(&edited).unwrap(); let ack = session .write(&mut store, capture(2, "continued remote")) .unwrap(); assert_eq!(ack.revision, 1); assert_eq!( store.get_draft(&draft.id).unwrap().unwrap().text, "user edited locally" ); assert_eq!(store.list_drafts().unwrap().len(), 2); assert!(store .list_drafts() .unwrap() .iter() .any(|draft| draft.text == "continued remote")); } #[test] fn capture_quota_failure_does_not_consume_sequence_or_create_partial_data() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); let mut store = Store::open(&path).unwrap(); let connection = rusqlite::Connection::open(&path).unwrap(); connection .execute("UPDATE capture_usage SET revision_count=50000", []) .unwrap(); let mut session = CaptureSession::default(); assert!(session .write(&mut store, capture(1, "retry me")) .unwrap_err() .starts_with("QUOTA:")); assert!(store.list_drafts().unwrap().is_empty()); connection .execute("UPDATE capture_usage SET revision_count=0", []) .unwrap(); assert_eq!( session .write(&mut store, capture(1, "retry me")) .unwrap() .revision, 1 ); } #[test] fn oversized_and_dm_capture_is_rejected_without_recording() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); assert!(session .write(&mut store, capture(1, &"x".repeat(65_537))) .is_err()); for path in ["/messages", "/messages/123", "/i/chat", "/i/chat/123"] { let mut value = capture(1, "private"); value.context_url = format!("https://x.com{path}"); assert!(session.write(&mut store, value).is_err()); } assert!(store.list_drafts().unwrap().is_empty()); } #[test] fn composer_quota_is_bounded() { let mut store = Store::open(":memory:").unwrap(); let mut session = CaptureSession::default(); for n in 0..200 { let mut value = capture(1, "snapshot"); value.composer_id = format!("composer-{n}"); session.write(&mut store, value).unwrap(); } let mut value = capture(1, "snapshot"); value.composer_id = "composer-extra".into(); assert!(session .write(&mut store, value) .unwrap_err() .starts_with("QUOTA:")); assert_eq!(store.list_drafts().unwrap().len(), 200); } #[test] fn newer_schema_is_never_silently_downgraded() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); let connection = rusqlite::Connection::open(&path).unwrap(); connection.pragma_update(None, "user_version", 2).unwrap(); assert!(Store::open(&path).err().unwrap().contains("newer schema")); } /// What a database file looks like after its first open: every table, index and the user version. /// Existing users' databases are opened by whatever code serves the vault, so this must never change. fn schema_dump(path: &std::path::Path) -> String { let connection = rusqlite::Connection::open(path).unwrap(); let mut statement = connection.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name").unwrap(); let rows: Vec = statement .query_map([], |row| Ok(format!("{} {}: {}", row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?.split_whitespace().collect::>().join(" ")))) .unwrap() .map(Result::unwrap) .collect(); let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap(); format!("{}\nuser_version {version}\n", rows.join("\n")) } #[test] fn the_database_schema_is_pinned() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); drop(Store::open(&path).unwrap()); let dump = schema_dump(&path); let expected = include_str!("fixtures/vault-schema-v1.txt").replace("\r\n", "\n"); assert_eq!(dump, expected, "the v1 schema changed: existing databases would no longer match"); } /// One database goes through a revision conflict, an archive and a restore, a capture that forks over a /// local edit and an export; then it is reopened and every draft, revision and exported row must be /// exactly what was acknowledged, with nothing from the refused writes. #[test] fn conflict_archive_capture_fork_and_export_lose_nothing_across_reopen() { let directory = tempdir().unwrap(); let path = directory.path().join("workspace.sqlite3"); let acknowledged; { let mut store = Store::open(&path).unwrap(); let first = store.save_draft(&input("keep", 0, "one")).unwrap(); let second = store.save_draft(&input("keep", first.revision, "two")).unwrap(); // A stale writer is refused and leaves no revision, no partial text. let stale = store.save_draft(&input("keep", first.revision, "stale overwrite")); assert!(stale.unwrap_err().starts_with("CONFLICT")); assert!(store.save_draft(&input("missing", 5, "x")).unwrap_err().starts_with("CONFLICT")); assert!(store.get_draft("missing").unwrap().is_none()); // Archive and restore are revisions; the text stays. let archived = store.set_status("keep", second.revision, "archived").unwrap(); assert!(store.set_status("keep", second.revision, "active").unwrap_err().starts_with("CONFLICT")); let restored = store.set_status("keep", archived.revision, "active").unwrap(); assert_eq!((restored.status.as_str(), restored.text.as_str()), ("active", "two")); // A captured draft, edited locally, is never overwritten by the next capture: it forks. let mut session = CaptureSession::default(); let ack = session.write(&mut store, capture(1, "captured first")).unwrap(); assert_eq!(ack.revision, 1); let captured = store.list_drafts().unwrap().into_iter().find(|draft| draft.source == "capture").unwrap(); store.save_draft(&SaveDraftInput { account_id: captured.account_id.clone(), ..input(&captured.id, captured.revision, "edited by hand") }).unwrap(); session.write(&mut store, capture(2, "captured second")).unwrap(); let exported = store.export().unwrap(); assert_eq!(exported.drafts.len(), 3, "keep, the edited capture and its fork"); acknowledged = (store.list_drafts().unwrap(), exported.revisions.clone()); assert_eq!(exported.revisions.len(), store.list_drafts().unwrap().iter().map(|d| store.revisions(&d.id).unwrap().len()).sum::()); let json = serde_json::to_value(&exported).unwrap(); assert!(json["drafts"][0].get("accountId").is_some() && json["revisions"][0].get("draftId").is_some() && json.get("exportedAt").is_some()); } let mut reopened = Store::open(&path).unwrap(); assert_eq!(reopened.list_drafts().unwrap(), acknowledged.0); assert_eq!(reopened.export().unwrap().revisions, acknowledged.1); let keep = reopened.revisions("keep").unwrap(); let texts: Vec<&str> = keep.iter().rev().map(|revision| revision.text.as_str()).collect(); assert_eq!(texts, ["one", "two", "two", "two"], "write, write, archive, restore; never the refused overwrite"); assert!(reopened.get_draft("keep").unwrap().unwrap().text != "stale overwrite"); }