use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::{fs, path::Path};
use iris_chat_core::{
classify_chat_input, AppAction, AppReconciler, AppState, AppUpdate, ChatInputShortcut,
ChatKind, FfiApp,
};
use nostr::Keys;
use rusqlite::Connection;
use tempfile::TempDir;
#[test]
fn note_to_self_search_opens_one_real_conversation() {
let dir = TempDir::new().unwrap();
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
assert!(app
.search("note to self".into(), None, 20)
.contacts
.is_empty());
app.dispatch(AppAction::CreateAccount {
name: "Alice".into(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let account = inbox.snapshot().account.unwrap();
assert_eq!(
app.chat_snapshot(account.public_key_hex.clone(), 20)
.unwrap()
.display_name,
"Note to self"
);
for query in ["note", "SELF", " note to self ", "Alice"] {
let result = app.search(query.into(), None, 20);
assert_eq!(result.contacts.len(), 1, "query: {query}");
assert_eq!(result.contacts[0].chat_id, account.public_key_hex);
assert_eq!(result.contacts[0].display_name, "Note to self");
assert!(result.people.is_empty());
}
assert!(
inbox.snapshot().chat_list.is_empty(),
"search must not create a chat"
);
assert!(app.search("unrelated".into(), None, 20).contacts.is_empty());
assert!(app
.search("note".into(), Some(account.public_key_hex.clone()), 20)
.contacts
.is_empty());
app.dispatch(AppAction::OpenChat {
chat_id: account.public_key_hex.clone(),
});
inbox.wait_until(Duration::from_secs(5), |state| {
state
.current_chat
.as_ref()
.is_some_and(|chat| chat.chat_id == account.public_key_hex)
});
assert_eq!(
inbox.snapshot().current_chat.unwrap().display_name,
"Note to self"
);
app.dispatch(AppAction::SendMessage {
chat_id: account.public_key_hex.clone(),
text: "Remember the tea".into(),
});
inbox.wait_until(Duration::from_secs(5), |state| {
state.current_chat.as_ref().is_some_and(|chat| {
chat.messages
.iter()
.any(|message| message.body == "Remember the tea")
})
});
let result = app.search("note".into(), None, 20);
assert_eq!(result.contacts.len(), 1);
assert_eq!(
result.contacts[0].last_message_preview.as_deref(),
Some("Remember the tea")
);
assert_eq!(inbox.snapshot().chat_list.len(), 1);
assert_eq!(inbox.snapshot().account.unwrap().display_name, "Alice");
assert_eq!(
app.chat_snapshot(account.public_key_hex.clone(), 20)
.unwrap()
.display_name,
"Note to self"
);
app.dispatch(AppAction::OpenChat {
chat_id: account.public_key_hex,
});
inbox.wait_until(Duration::from_secs(5), |state| {
state
.current_chat
.as_ref()
.is_some_and(|chat| chat.messages.len() == 1)
});
app.shutdown();
}
#[test]
fn ffi_search_works_against_pre_search_database() {
let dir = TempDir::new().unwrap();
seed_pre_search_database(dir.path());
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let result = app.search("abracadabra".to_string(), None, 20);
assert_eq!(result.messages.len(), 1, "{:?}", result.messages);
assert!(result.messages[0].body.contains("abracadabra"));
app.shutdown();
}
fn seed_pre_search_database(data_dir: &Path) {
fs::create_dir_all(data_dir).unwrap();
let conn = Connection::open(data_dir.join("core.sqlite3")).unwrap();
conn.execute_batch(
r#"
CREATE TABLE threads (chat_id TEXT PRIMARY KEY);
CREATE TABLE messages (
chat_id TEXT NOT NULL REFERENCES threads(chat_id) ON DELETE CASCADE,
id TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'user',
author TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
is_outgoing INTEGER NOT NULL DEFAULT 0,
created_at_secs INTEGER NOT NULL DEFAULT 0,
expires_at_secs INTEGER,
delivery TEXT NOT NULL DEFAULT 'sent',
attachments_json TEXT NOT NULL DEFAULT '[]',
reactions_json TEXT NOT NULL DEFAULT '[]',
reactors_json TEXT NOT NULL DEFAULT '[]',
source_event_id TEXT,
recipient_deliveries_json TEXT NOT NULL DEFAULT '[]',
delivery_trace_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (chat_id, id)
);
INSERT INTO threads(chat_id) VALUES ('npub-pre-search');
INSERT INTO messages(chat_id, id, author, body, is_outgoing, created_at_secs)
VALUES ('npub-pre-search', '1', 'alice', 'abracadabra magic word', 1, 42);
PRAGMA user_version = 10;
"#,
)
.unwrap();
}
#[test]
fn ffi_search_restores_followed_people_from_sqlite() {
let dir = TempDir::new().unwrap();
let bootstrap = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
bootstrap.shutdown();
let owner = Keys::generate().public_key().to_hex();
let conn = Connection::open(dir.path().join("core.sqlite3")).unwrap();
conn.execute(
"INSERT INTO user_discovery_state(id, follow_event_id, follow_created_at_secs)
VALUES (1, 'follow-head', 10)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO user_discovery_users(owner_pubkey_hex, follow_position, petname)
VALUES (?1, 0, 'Ally')",
[&owner],
)
.unwrap();
conn.execute(
"INSERT INTO owner_profiles(
owner_pubkey_hex, name, display_name, picture, about, updated_at_secs
) VALUES (?1, 'alice', 'Alice Public', 'https://example.com/alice.jpg',
'Builds Rust apps', 8)",
[&owner],
)
.unwrap();
seed_messaging_support(&conn, &owner);
drop(conn);
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let result = app.search("rust".to_string(), None, 20);
assert_eq!(result.people.len(), 1);
assert_eq!(result.people[0].owner_pubkey_hex, owner);
assert_eq!(result.people[0].display_label, "Ally");
assert_eq!(
result.people[0].profile_label.as_deref(),
Some("Alice Public")
);
assert_eq!(
result.people[0].picture_url.as_deref(),
Some("https://example.com/alice.jpg")
);
assert!(app
.search("rust".to_string(), Some("chat".to_string()), 20)
.people
.is_empty());
app.shutdown();
}
#[test]
fn ffi_search_hides_global_people_without_messaging_support() {
let dir = TempDir::new().unwrap();
FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
)
.shutdown();
let owner = Keys::generate().public_key().to_hex();
let conn = Connection::open(dir.path().join("core.sqlite3")).unwrap();
conn.execute(
"INSERT INTO profile_search_candidates(
owner_pubkey_hex, name, aliases_json, nip05, picture,
created_at_secs, cached_at_secs
) VALUES (?1, 'Sirius', '[\"Sirius Business\"]', 'sirius@iris.to',
NULL, 1, 1)",
[&owner],
)
.unwrap();
drop(conn);
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let result = app.search("sirius business".to_string(), None, 20);
assert!(
result.people.is_empty(),
"unknown profiles must stay hidden"
);
app.shutdown();
}
#[test]
fn blocked_direct_chat_does_not_reappear_in_people() {
let alice = TempDir::new().unwrap();
let app = FfiApp::new(
alice.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
let bob_owner = create_chat_and_send(&app, &inbox, &bob_npub, "hello bob");
let conn = Connection::open(alice.path().join("core.sqlite3")).unwrap();
conn.execute(
"INSERT OR REPLACE INTO user_discovery_users(
owner_pubkey_hex, follow_position, petname
) VALUES (?1, 0, 'Blocked Regression')",
[&bob_owner],
)
.unwrap();
seed_messaging_support(&conn, &bob_owner);
conn.execute("INSERT INTO profile_search_candidates VALUES (?1, 'Blocked Regression', '[]', NULL, NULL, 1, 1)", [&bob_owner]).unwrap();
drop(conn);
assert!(app
.search("blocked regression".to_string(), None, 20)
.people
.is_empty());
app.dispatch(AppAction::SetUserBlocked {
owner_pubkey_hex: bob_owner.clone(),
blocked: true,
});
inbox.wait_until(Duration::from_secs(5), |state| {
state.preferences.blocked_owner_pubkeys.contains(&bob_owner)
&& state.chat_list.iter().any(|chat| {
chat.chat_id == bob_owner
&& chat.last_message_preview.as_deref() == Some("hello bob")
})
});
let history = app.search("hello bob".to_string(), Some(bob_owner.clone()), 20);
assert_eq!(history.messages.len(), 1, "blocking preserves history");
assert_eq!(history.messages[0].body, "hello bob");
assert!(app
.search("blocked regression".to_string(), None, 20)
.people
.is_empty());
app.shutdown();
}
#[test]
fn classifies_npub_invite_and_plain_text_inputs() {
assert!(classify_chat_input("hello world".to_string()).is_none());
assert!(classify_chat_input(" ".to_string()).is_none());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
let shortcut = classify_chat_input(bob_npub.clone()).expect("npub is a shortcut");
match shortcut {
ChatInputShortcut::DirectPeer {
peer_input,
npub,
pubkey_hex,
display,
} => {
assert_eq!(npub, bob_npub);
assert!(!pubkey_hex.is_empty());
assert!(peer_input.starts_with("npub"));
assert!(!display.is_empty());
assert!(
!display.contains("npub"),
"search should show a friendly label: {display}"
);
assert!(!display.contains(&pubkey_hex));
match classify_chat_input(pubkey_hex).expect("hex user ID is a shortcut") {
ChatInputShortcut::DirectPeer {
display: hex_display,
..
} => {
assert_eq!(
display, hex_display,
"the same user keeps the same friendly label"
);
}
other => panic!("expected DirectPeer, got {other:?}"),
}
}
other => panic!("expected DirectPeer, got {other:?}"),
}
let padded = format!(" {bob_npub} ");
assert!(matches!(
classify_chat_input(padded),
Some(ChatInputShortcut::DirectPeer { .. })
));
let invite = "https://chat.iris.to/#abc123".to_string();
let shortcut = classify_chat_input(invite.clone()).expect("invite shortcut");
match shortcut {
ChatInputShortcut::Invite {
invite_input,
display,
} => {
assert_eq!(invite_input, invite);
assert!(display.contains("chat.iris.to"), "{display}");
}
other => panic!("expected Invite, got {other:?}"),
}
}
#[test]
fn ffi_search_returns_grouped_contacts_groups_and_messages() {
let alice = TempDir::new().unwrap();
let app = FfiApp::new(
alice.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: "Alice".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| state.account.is_some());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "Bob");
let carol_npub = ensure_account(&TempDir::new().unwrap(), "Carol");
let dora_npub = ensure_account(&TempDir::new().unwrap(), "Dora");
let bob_chat = create_chat_and_send(&app, &inbox, &bob_npub, "hello there bob");
let _carol_chat = create_chat_and_send(&app, &inbox, &carol_npub, "carol catch up later");
let group_chat =
create_group_and_send(&app, &inbox, "Project Hello", &dora_npub, "kickoff agenda");
let _ = group_chat; app.dispatch(AppAction::SetChatDraft {
chat_id: bob_chat.clone(),
text: "unsent draft idea".to_string(),
});
inbox.wait_until(Duration::from_secs(5), |state| {
state
.chat_list
.iter()
.any(|c| c.chat_id == bob_chat && c.draft == "unsent draft idea")
});
let final_state = inbox.snapshot();
assert!(
final_state.chat_list.len() >= 3,
"expected >=3 chats, got {}",
final_state.chat_list.len()
);
let result = app.search("hello".to_string(), None, 20);
assert_eq!(result.query, "hello");
assert!(result.scope_chat_id.is_none());
assert!(result
.groups
.iter()
.any(|g| g.display_name == "Project Hello"));
assert!(result
.messages
.iter()
.any(|m| m.body.contains("hello there bob")));
assert!(result.messages.iter().any(|m| m.chat_id == bob_chat));
for hit in &result.messages {
match hit.chat_kind {
ChatKind::Direct | ChatKind::Group => {}
}
assert!(!hit.chat_display_name.is_empty(), "{hit:?}");
}
let draft_result = app.search("unsent".to_string(), None, 20);
assert!(draft_result
.contacts
.iter()
.any(|contact| contact.chat_id == bob_chat && contact.draft == "unsent draft idea"));
let scoped = app.search("hello".to_string(), Some(bob_chat.clone()), 20);
assert!(scoped.contacts.is_empty());
assert!(scoped.groups.is_empty());
assert!(
scoped.shortcut.is_none(),
"scoped search must not surface global shortcuts"
);
assert!(!scoped.messages.is_empty());
for hit in &scoped.messages {
assert_eq!(hit.chat_id, bob_chat);
}
let blank = app.search(" ".to_string(), None, 20);
assert!(blank.is_empty());
let bob_npub = ensure_account(&TempDir::new().unwrap(), "BobShortcut");
let with_npub = app.search(bob_npub.clone(), None, 20);
assert!(matches!(
with_npub.shortcut,
Some(ChatInputShortcut::DirectPeer { ref npub, .. }) if npub == &bob_npub
));
}
#[derive(Clone)]
struct ReconcilerInbox {
state: Arc<Mutex<AppState>>,
}
impl ReconcilerInbox {
fn install(app: &FfiApp) -> Self {
let inbox = Self {
state: Arc::new(Mutex::new(AppState::empty())),
};
let collector = Box::new(StateCollector {
slot: inbox.state.clone(),
});
app.listen_for_updates(collector);
inbox
}
fn wait_until<F>(&self, timeout: Duration, mut predicate: F)
where
F: FnMut(&AppState) -> bool,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(guard) = self.state.lock() {
if predicate(&guard) {
return;
}
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("predicate never observed within {timeout:?}");
}
fn snapshot(&self) -> AppState {
self.state.lock().unwrap().clone()
}
fn current_chat_id_different_from(&self, prior: &str, timeout: Duration) -> String {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Ok(guard) = self.state.lock() {
if let Some(chat) = guard.current_chat.as_ref() {
if chat.chat_id != prior {
return chat.chat_id.clone();
}
}
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("current_chat never advanced past {prior:?} within {timeout:?}");
}
}
fn prior_chat_id(inbox: &ReconcilerInbox) -> String {
inbox
.snapshot()
.current_chat
.map(|chat| chat.chat_id)
.unwrap_or_default()
}
fn create_chat_and_send(
app: &FfiApp,
inbox: &ReconcilerInbox,
peer_input: &str,
body: &str,
) -> String {
let prior = prior_chat_id(inbox);
app.dispatch(AppAction::CreateChat {
peer_input: peer_input.to_string(),
});
let chat_id = inbox.current_chat_id_different_from(&prior, Duration::from_secs(5));
app.dispatch(AppAction::SendMessage {
chat_id: chat_id.clone(),
text: body.to_string(),
});
let expected = body.to_string();
inbox.wait_until(Duration::from_secs(5), |state| {
state
.chat_list
.iter()
.find(|c| c.chat_id == chat_id)
.and_then(|c| c.last_message_preview.as_ref())
.map(|preview| preview.contains(&expected))
.unwrap_or(false)
});
chat_id
}
fn create_group_and_send(
app: &FfiApp,
inbox: &ReconcilerInbox,
name: &str,
member_npub: &str,
body: &str,
) -> String {
let prior = prior_chat_id(inbox);
app.dispatch(AppAction::CreateGroup {
name: name.to_string(),
member_inputs: vec![member_npub.to_string()],
});
let chat_id = inbox.current_chat_id_different_from(&prior, Duration::from_secs(5));
app.dispatch(AppAction::SendMessage {
chat_id: chat_id.clone(),
text: body.to_string(),
});
let expected = body.to_string();
inbox.wait_until(Duration::from_secs(5), |state| {
state
.chat_list
.iter()
.find(|c| c.chat_id == chat_id)
.and_then(|c| c.last_message_preview.as_ref())
.map(|preview| preview.contains(&expected))
.unwrap_or(false)
});
chat_id
}
struct StateCollector {
slot: Arc<Mutex<AppState>>,
}
impl AppReconciler for StateCollector {
fn reconcile(&self, update: AppUpdate) {
if let AppUpdate::FullState(state) = update {
if let Ok(mut guard) = self.slot.lock() {
if state.rev >= guard.rev {
*guard = state;
}
}
}
}
}
fn ensure_account(temp: &TempDir, name: &str) -> String {
let app = FfiApp::new(
temp.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let inbox = ReconcilerInbox::install(&app);
app.dispatch(AppAction::CreateAccount {
name: name.to_string(),
});
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if Instant::now() > deadline {
panic!("account creation timeout for {name}");
}
let snapshot = inbox.state.lock().unwrap().clone();
if let Some(account) = snapshot.account {
let npub = account.npub;
app.shutdown();
return npub;
}
std::thread::sleep(Duration::from_millis(2));
}
}
fn seed_messaging_support(conn: &Connection, owner: &str) {
let devices = serde_json::json!([{
"identity_pubkey_hex": Keys::generate().public_key().to_hex(),
"created_at_secs": 1
}]);
conn.execute("INSERT OR REPLACE INTO app_keys(owner_pubkey_hex, created_at_secs, devices_json) VALUES (?1, 1, ?2)", rusqlite::params![owner, devices.to_string()]).unwrap();
}
#[test]
fn ffi_people_search_filters_before_limit_and_restores_only_supported_people_offline() {
let dir = TempDir::new().unwrap();
FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
)
.shutdown();
let supported = Keys::generate().public_key().to_hex();
let unknown = Keys::generate().public_key().to_hex();
let revoked = Keys::generate().public_key().to_hex();
let conn = Connection::open(dir.path().join("core.sqlite3")).unwrap();
for (owner, name) in [
(&unknown, "Alice A"),
(&revoked, "Alice B"),
(&supported, "Alice Z"),
] {
conn.execute(
"INSERT INTO profile_search_candidates VALUES (?1, ?2, '[]', NULL, NULL, 1, 1)",
[owner, &name.to_string()],
)
.unwrap();
}
seed_messaging_support(&conn, &supported);
conn.execute("INSERT INTO app_keys VALUES (?1, 2, '[]')", [&revoked])
.unwrap();
drop(conn);
for _ in 0..2 {
let app = FfiApp::new(
dir.path().to_string_lossy().to_string(),
String::new(),
"test".to_string(),
);
let results = app.search("alice".to_string(), None, 1);
assert_eq!(results.people.len(), 1);
assert_eq!(results.people[0].owner_pubkey_hex, supported);
app.shutdown();
}
}
#[test]
fn ffi_people_search_ranks_private_nicknames_and_public_names_after_restart() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_string_lossy().to_string();
FfiApp::new(path.clone(), String::new(), "test".to_string()).shutdown();
let conn = Connection::open(dir.path().join("core.sqlite3")).unwrap();
conn.execute("INSERT INTO user_discovery_state(id) VALUES (1)", [])
.unwrap();
let cases = [
("Joanna", None, None),
("Public Second", Some("Family Anna"), None),
("Public Third", Some("Anna Smith"), None),
("Public Fourth", Some("Ann"), None),
("Rihanna", None, None),
("O'Ann-Marie", None, None),
("Other Person", None, Some("Ann")),
];
let mut owners = Vec::new();
for (position, (name, nickname, about)) in cases.iter().enumerate() {
let owner = Keys::generate().public_key().to_hex();
conn.execute(
"INSERT INTO owner_profiles(owner_pubkey_hex, name, nickname, about, updated_at_secs)
VALUES (?1, ?2, ?3, ?4, 1)",
rusqlite::params![owner, name, nickname, about],
)
.unwrap();
conn.execute(
"INSERT INTO user_discovery_users(owner_pubkey_hex, follow_position)
VALUES (?1, ?2)",
rusqlite::params![owner, position],
)
.unwrap();
seed_messaging_support(&conn, &owner);
owners.push(owner);
}
drop(conn);
for _ in 0..2 {
let app = FfiApp::new(path.clone(), String::new(), "test".to_string());
let result = app.search(" ANN ".to_string(), None, 20);
assert_eq!(
result
.people
.iter()
.map(|person| &person.owner_pubkey_hex)
.collect::<Vec<_>>(),
[3, 1, 2, 5, 0, 4, 6].map(|index| &owners[index]),
);
assert_eq!(result.people[0].display_label, "Ann");
assert_eq!(
result.people[0].profile_label.as_deref(),
Some("Public Fourth")
);
assert_eq!(
app.search("ann".to_string(), None, 1).people[0].owner_pubkey_hex,
owners[3]
);
for query in ["smi ann", "AnnaSmith", "Public Third"] {
let result = app.search(query.to_string(), None, 20);
assert_eq!(result.people.len(), 1, "{query}");
assert_eq!(result.people[0].owner_pubkey_hex, owners[2], "{query}");
assert_eq!(result.people[0].display_label, "Anna Smith");
}
assert!(app
.search("unrelated".to_string(), None, 20)
.people
.is_empty());
app.shutdown();
}
}