iris-chat 0.1.59

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;
use nostr::{EventBuilder, Keys, Tag, Timestamp};
use tempfile::TempDir;

const CLEARLY_FUTURE_OFFSET_SECS: u64 = 60 * 60;

fn follow_event(keys: &Keys, created_at: u64, followed: &[PublicKey]) -> Event {
    EventBuilder::new(Kind::ContactList, "")
        .tags(followed.iter().copied().map(Tag::public_key))
        .custom_created_at(Timestamp::from(created_at))
        .sign_with_keys(keys)
        .unwrap()
}

fn positions(cache: &UserDiscoveryCache, owners: &[&Keys]) -> Vec<u32> {
    owners
        .iter()
        .map(|owner| cache.users[&owner.public_key().to_hex()].follow_position)
        .collect()
}

#[test]
fn relay_people_graph_fetches_mutes_and_restores_them_offline() {
    let relay = crate::local_relay::TestRelay::start();
    let owner = Keys::generate();
    let friend = Keys::generate();
    let target = Keys::generate();
    let client = Client::new(Keys::generate());
    let relay_urls = relay_urls_from_strings(&[relay.url().to_string()]);
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    let cache = runtime.block_on(async {
        ensure_session_relays_configured(&client, &relay_urls).await;
        connect_client_with_timeout(&client, Duration::from_secs(2)).await;
        client
            .send_event(&follow_event(&owner, 1, &[friend.public_key()]))
            .await
            .unwrap();
        let muted = EventBuilder::new(Kind::from(10_000), "")
            .tags([Tag::public_key(target.public_key())])
            .custom_created_at(Timestamp::from(2))
            .sign_with_keys(&friend)
            .unwrap();
        client.send_event(&muted).await.unwrap();
        let result = fetch_user_discovery(
            client.clone(),
            relay_urls,
            owner.public_key(),
            UserDiscoveryCache::default(),
        )
        .await;
        client.disconnect().await;
        result.cache
    });
    let temp = TempDir::new().unwrap();
    let mut store = AppStore::new(open_database(temp.path()).unwrap());
    store.replace_user_discovery(&cache).unwrap();
    let restored = store.load_user_discovery().unwrap();
    assert_eq!(restored, cache);
    let graph = nostr_social_graph::SocialGraph::from_binary(
        &owner.public_key().to_hex(),
        restored.social_graph.as_deref().unwrap(),
    )
    .unwrap();
    assert!(graph.is_overmuted(&target.public_key().to_hex(), 1.0));
}

#[test]
fn relay_fetch_keeps_all_follows_and_preserves_social_order_until_root_changes() {
    let relay = crate::local_relay::TestRelay::start();
    let root = Keys::generate();
    let alice = Keys::generate();
    let bob = Keys::generate();
    let carol = Keys::generate();
    let owners = [&alice, &bob, &carol];
    let root_follow = follow_event(
        &root,
        100,
        &owners
            .iter()
            .map(|owner| owner.public_key())
            .collect::<Vec<_>>(),
    );
    let alice_follows_carol = follow_event(&alice, 100, &[carol.public_key()]);
    let relay_urls = relay_urls_from_strings(&[relay.url().to_string()]);
    let publisher = Client::new(Keys::generate());
    let discovery_client = Client::new(Keys::generate());
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    let first = runtime.block_on(async {
        ensure_session_relays_configured(&publisher, &relay_urls).await;
        connect_client_with_timeout(&publisher, Duration::from_secs(2)).await;
        publisher.send_event(&root_follow).await.unwrap();
        publisher.send_event(&alice_follows_carol).await.unwrap();
        fetch_user_discovery(
            discovery_client.clone(),
            relay_urls.clone(),
            root.public_key(),
            UserDiscoveryCache::default(),
        )
        .await
    });
    assert_eq!(first.cache.users.len(), owners.len());
    assert_eq!(positions(&first.cache, &owners), [1, 2, 0]);
    assert!(first.cache.social_rank_ready);
    assert_eq!(
        first.cache.social_friend_support[&carol.public_key().to_hex()],
        1
    );

    let temp = TempDir::new().unwrap();
    let mut store = AppStore::new(open_database(temp.path()).unwrap());
    store.replace_user_discovery(&first.cache).unwrap();
    assert_eq!(
        positions(&store.load_user_discovery().unwrap(), &owners),
        [1, 2, 0]
    );
    let first_social_support = first.cache.social_friend_support.clone();

    let second = runtime.block_on(async {
        let future = follow_event(
            &alice,
            unix_now().get() + CLEARLY_FUTURE_OFFSET_SECS,
            &[carol.public_key()],
        );
        publisher.send_event(&future).await.unwrap();
        let result = fetch_user_discovery(
            discovery_client.clone(),
            relay_urls.clone(),
            root.public_key(),
            first.cache,
        )
        .await;
        result
    });
    assert_eq!(second.cache.users.len(), owners.len());
    assert_eq!(positions(&second.cache, &owners), [1, 2, 0]);
    assert_eq!(second.cache.social_friend_support, first_social_support);
    assert!(second.detail.contains("social_failed_chunks=1"));
    store.replace_user_discovery(&second.cache).unwrap();
    assert_eq!(
        positions(&store.load_user_discovery().unwrap(), &owners),
        [1, 2, 0]
    );

    let changed_owners = [&bob, &carol];
    let changed_root = follow_event(
        &root,
        101,
        &changed_owners
            .iter()
            .map(|owner| owner.public_key())
            .collect::<Vec<_>>(),
    );
    let third = runtime.block_on(async {
        let future = follow_event(
            &bob,
            unix_now().get() + CLEARLY_FUTURE_OFFSET_SECS,
            &[carol.public_key()],
        );
        publisher.send_event(&changed_root).await.unwrap();
        publisher.send_event(&future).await.unwrap();
        let result = fetch_user_discovery(
            discovery_client.clone(),
            relay_urls,
            root.public_key(),
            second.cache,
        )
        .await;
        let _ = publisher.shutdown().await;
        let _ = discovery_client.shutdown().await;
        result
    });
    assert_eq!(third.cache.users.len(), changed_owners.len());
    assert_eq!(positions(&third.cache, &changed_owners), [0, 1]);
    assert_eq!(third.cache.follow_event_id, Some(changed_root.id.to_hex()));
    assert!(!third.cache.social_rank_ready);
    assert!(third.cache.social_friend_support.is_empty());
    assert!(third.detail.contains("social_failed_chunks=1"));
}

#[test]
fn local_relay_ignores_future_root_and_preserves_metadata_and_unfollow() {
    let relay = crate::local_relay::TestRelay::start();
    let local_owner = Keys::generate();
    let followed_owner = Keys::generate();
    let relay_urls = relay_urls_from_strings(&[relay.url().to_string()]);
    let follow = EventBuilder::new(Kind::ContactList, "")
        .tags([Tag::parse([
            "p",
            followed_owner.public_key().to_hex().as_str(),
            "",
            "Relay friend",
        ])
        .unwrap()])
        .custom_created_at(Timestamp::from(100))
        .sign_with_keys(&local_owner)
        .unwrap();
    let metadata = EventBuilder::new(
        Kind::Metadata,
        r#"{"name":"relay-alice","display_name":"Relay Alice","about":"Local test"}"#,
    )
    .custom_created_at(Timestamp::from(100))
    .sign_with_keys(&followed_owner)
    .unwrap();
    let publisher = Client::new(Keys::generate());
    let discovery_client = Client::new(Keys::generate());
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    let first = runtime.block_on(async {
        ensure_session_relays_configured(&publisher, &relay_urls).await;
        connect_client_with_timeout(&publisher, Duration::from_secs(2)).await;
        publisher.send_event(&follow).await.unwrap();
        publisher.send_event(&metadata).await.unwrap();
        fetch_user_discovery(
            discovery_client.clone(),
            relay_urls.clone(),
            local_owner.public_key(),
            UserDiscoveryCache::default(),
        )
        .await
    });
    assert_eq!(first.cache.users.len(), 1);
    assert_eq!(
        first
            .cache
            .users
            .get(&followed_owner.public_key().to_hex())
            .and_then(|user| user.petname.as_deref()),
        Some("Relay friend")
    );
    assert_eq!(first.metadata_events.len(), 1);
    assert_eq!(first.cache.follow_event_id, Some(follow.id.to_hex()));

    let future = follow_event(
        &local_owner,
        unix_now().get() + CLEARLY_FUTURE_OFFSET_SECS,
        &[],
    );
    let mut poisoned = first.cache;
    poisoned.follow_event_id = Some(future.id.to_hex());
    poisoned.follow_created_at_secs = future.created_at.as_secs();
    poisoned.users.clear();
    let second = runtime.block_on(async {
        publisher.send_event(&future).await.unwrap();
        fetch_user_discovery(
            discovery_client.clone(),
            relay_urls.clone(),
            local_owner.public_key(),
            poisoned,
        )
        .await
    });
    assert_eq!(
        second.detail,
        "follows=1 metadata_failed_chunks=0 social_failed_chunks=0"
    );
    assert_eq!(second.cache.users.len(), 1);
    assert_eq!(second.cache.follow_event_id, Some(follow.id.to_hex()));

    let unfollow = follow_event(&local_owner, 101, &[]);
    let third = runtime.block_on(async {
        publisher.send_event(&unfollow).await.unwrap();
        let result = fetch_user_discovery(
            discovery_client.clone(),
            relay_urls,
            local_owner.public_key(),
            second.cache,
        )
        .await;
        let _ = publisher.shutdown().await;
        let _ = discovery_client.shutdown().await;
        result
    });
    assert!(third.cache.users.is_empty());
    assert_eq!(third.cache.follow_event_id, Some(unfollow.id.to_hex()));
}