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")); }