#[test]
fn direct_send_readiness_advances_from_appkeys_and_invite_state() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let mut engine = test_protocol_engine(&owner, &device);
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::MissingLocalAppKeys
);
assert!(engine
.authenticate_local_owner_for_sending(&peer_owner)
.is_err());
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::MissingLocalAppKeys,
"a mismatched owner key must not bypass local roster readiness"
);
observe_current_device_appkeys_for_test(&mut engine, &owner, &device);
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::MissingPeerAppKeys
);
observe_peer_appkeys_for_test(
&mut engine,
&peer_owner,
&[peer_device.public_key()],
1,
);
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::MissingPeerInviteOrSession
);
observe_peer_device_invite_for_test(&mut engine, &peer_owner, &peer_device, 2);
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::Ready
);
}
#[test]
fn nsec_restore_queues_until_local_device_approval_is_available() {
let owner = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let owner_nsec = owner
.secret_key()
.to_bech32()
.unwrap_or_else(|_| owner.secret_key().to_secret_hex());
let (update_tx, update_rx) = flume::unbounded();
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let mut core = AppCore::new(
update_tx,
flume::unbounded().0,
temp_dir.path().to_string_lossy().to_string(),
Arc::new(RwLock::new(AppState::empty())),
);
core.restore_primary_session(&owner_nsec);
assert!(core.defer_owner_app_keys_publish);
assert!(!core.app_keys.contains_key(&owner.public_key().to_hex()));
assert!(
core.debug_log.iter().any(|entry| {
entry.category == "profile.metadata.fetch"
&& entry.detail.contains("reason=session_restore")
&& entry.detail.contains(&owner.public_key().to_hex())
}),
"a clean secret-key restore should fetch the local owner's public profile"
);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_peer_appkeys_for_test(
engine,
&peer_owner,
&[peer_device.public_key()],
1,
);
observe_peer_device_invite_for_test(engine, &peer_owner, &peer_device, 2);
assert_eq!(
engine.direct_send_readiness(peer_owner.public_key()),
DirectSendReadiness::MissingLocalAppKeys,
"holding the account key must not bypass the device approval needed by recipients"
);
}
let chat_id = peer_owner.public_key().to_hex();
core.send_direct_message(&chat_id, "recovered hello", UnixSeconds(3), None);
let message = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("sent message");
assert_eq!(message.delivery, DeliveryState::Queued);
assert!(message.delivery_trace.outer_event_ids.is_empty());
assert!(update_rx.try_iter().all(|update| match update {
AppUpdate::NearbyPublishedEvent { event_json, .. } => serde_json::from_str::<Event>(&event_json)
.map(|event| !is_app_keys_event(&event))
.unwrap_or(true),
_ => true,
}), "recovery must not publish a provisional one-device AppKeys roster");
let device = core.logged_in.as_ref().unwrap().device_keys.public_key();
core.complete_owner_registration_lookup(
core.relay_status_watch_generation, owner.public_key(), device, 2, 2, vec![],
);
let sent = &core.threads[&chat_id].messages[0];
assert!(!sent.delivery_trace.outer_event_ids.is_empty(), "approval must automatically drain the original message");
let response = pending_events_with_kind(&core, INVITE_RESPONSE_KIND).into_iter().next().unwrap();
assert!(response.tags.iter().any(|tag| tag.as_slice()[0] == "owner-proof"),
"the first send must carry the locally signed approval without waiting for server echo");
}
#[test]
fn signed_device_approval_drains_message_stuck_on_missing_local_app_keys() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let mut core = logged_in_test_core("owner-auth-recovers-queue", &owner, &device);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_peer_appkeys_for_test(
engine,
&peer_owner,
&[peer_device.public_key()],
1,
);
observe_peer_device_invite_for_test(engine, &peer_owner, &peer_device, 2);
}
let chat_id = peer_owner.public_key().to_hex();
core.send_direct_message(&chat_id, "stuck hello", UnixSeconds(3), None);
let queued = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("queued message");
assert_eq!(queued.delivery, DeliveryState::Queued);
assert!(queued.delivery_trace.outer_event_ids.is_empty());
let queued_id = queued.id.clone();
core.protocol_engine
.as_mut()
.expect("protocol engine")
.authenticate_local_owner_for_sending(&owner)
.expect("owner authentication");
core.retry_protocol_engine_pending_work("test_owner_authentication");
assert!(core.threads[&chat_id].messages[0].delivery_trace.outer_event_ids.is_empty(),
"account-key possession must not replace the signed approval needed by recipients");
core.handle_relay_event(signed_app_keys_authorization_event(&owner, device.public_key(), 10));
let sent = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("recovered message");
assert_eq!(sent.id, queued_id, "a queued message must keep its final event ID");
assert!(!sent.delivery_trace.outer_event_ids.is_empty());
}
#[test]
fn appcore_direct_text_queues_until_subscription_state_makes_peer_ready() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let mut core = logged_in_test_core("direct-init-queue-drain", &owner, &device);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_current_device_appkeys_for_test(engine, &owner, &device);
}
let chat_id = peer_owner.public_key().to_hex();
core.send_direct_message(
&chat_id,
"queued hello\nnhash1abc123/iris-logo.png",
UnixSeconds(10),
None,
);
let queued_message = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("queued message")
.clone();
assert_eq!(queued_message.delivery, DeliveryState::Queued);
assert!(nostr::EventId::from_hex(&queued_message.id).is_ok(), "queue with the final event hash");
assert_eq!(queued_message.body, "queued hello");
assert_eq!(queued_message.attachments.len(), 1);
assert_eq!(queued_message.attachments[0].filename, "iris-logo.png");
assert!(queued_message.delivery_trace.outer_event_ids.is_empty());
assert!(core.pending_relay_publishes.is_empty());
let plan = core
.compute_protocol_subscription_plan()
.expect("protocol subscription plan");
assert!(
plan.roster_authors.contains(&chat_id),
"queued direct message should keep peer AppKeys in subscription interest"
);
let peer_app_keys_event = AppKeys::new(vec![DeviceEntry::new(peer_device.public_key(), 11)])
.get_event_at(peer_owner.public_key(), 11)
.sign_with_keys(&peer_owner)
.expect("signed peer appkeys");
let app_keys_batch = core
.protocol_engine
.as_mut()
.expect("protocol engine")
.ingest_app_keys_event(&peer_app_keys_event)
.expect("peer appkeys event");
core.process_protocol_engine_retry_batch("test_peer_appkeys", app_keys_batch);
let still_queued = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("still queued message");
assert_eq!(still_queued.delivery, DeliveryState::Queued);
assert!(core.pending_relay_publishes.is_empty());
let mut receiver = logged_in_test_core("queued-timestamp-recipient", &peer_owner, &peer_device);
receiver.handle_relay_event(signed_app_keys_authorization_event(&owner, device.public_key(), 1));
let invite = receiver.protocol_engine.as_mut().unwrap().local_invite().unwrap();
let invite_event = nostr_double_ratchet::invite_unsigned_event(&invite)
.expect("invite event")
.sign_with_keys(&peer_device)
.expect("signed invite");
let invite_batch = core
.protocol_engine
.as_mut()
.expect("protocol engine")
.observe_invite_event(&invite_event)
.expect("observe invite");
core.process_protocol_engine_retry_batch("test_peer_invite", invite_batch);
let thread = core.threads.get(&chat_id).expect("thread after drain");
assert_eq!(thread.messages.len(), 1);
let drained = &thread.messages[0];
assert_eq!(
drained.id, queued_message.id,
"drain must preserve the final event ID assigned when queued"
);
assert_eq!(drained.body, "queued hello");
assert_eq!(drained.attachments, queued_message.attachments);
assert_eq!(drained.created_at_secs, queued_message.created_at_secs);
assert!(!drained.delivery_trace.outer_event_ids.is_empty());
assert!(!core.pending_relay_publishes.is_empty());
for kind in [INVITE_RESPONSE_KIND, MESSAGE_EVENT_KIND] {
for event in pending_events_with_kind(&core, kind) {
receiver.handle_relay_event(event);
}
}
let received = &receiver.threads[&owner.public_key().to_hex()].messages[0];
assert_eq!(received.id, queued_message.id, "recipient must index the same event");
assert_eq!(received.created_at_secs, queued_message.created_at_secs,
"the recipient must see when the queued message was written, not when it drained");
}
#[test]
fn appcore_direct_queue_keeps_distinct_final_ids_across_restart_and_retry() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let chat_id = peer_owner.public_key().to_hex();
let (mut core, _updates, dir) =
logged_in_test_core_with_updates("stable-queued-events", &owner, &device);
let authored_at = unix_now();
for _ in 0..2 {
core.send_direct_message(
&chat_id,
"same message\nnhash1abc123/iris-logo.png",
authored_at,
Some(authored_at.get() + 3600),
);
}
let ids = core.threads[&chat_id]
.messages
.iter()
.map(|message| message.id.clone())
.collect::<Vec<_>>();
assert_eq!(
ids.len(),
2,
"intentional identical sends must remain distinct"
);
assert_ne!(ids[0], ids[1]);
let events = ids
.iter()
.map(|id| {
core.app_store
.load_outgoing_event(&chat_id, id)
.unwrap()
.unwrap()
})
.collect::<Vec<_>>();
for (id, event) in ids.iter().zip(&events) {
assert_eq!(event.id.unwrap().to_hex(), *id);
assert_eq!(event.content, "same message\nnhash1abc123/iris-logo.png");
}
core.persist_best_effort();
drop(core);
let mut core =
logged_in_test_core_at_data_dir(&owner, &device, dir.path().to_string_lossy().into_owned());
core.open_chat(&chat_id);
assert_eq!(core.threads[&chat_id].messages.len(), 2);
{
let engine = core.protocol_engine.as_mut().unwrap();
observe_current_device_appkeys_for_test(engine, &owner, &device);
observe_peer_appkeys_for_test(engine, &peer_owner, &[peer_device.public_key()], 1);
observe_peer_device_invite_for_test(engine, &peer_owner, &peer_device, 2);
}
assert!(core.drain_queued_direct_text_messages("test_restart"));
assert!(!core.drain_queued_direct_text_messages("test_retry"));
core.persist_best_effort();
let messages = &core.threads[&chat_id].messages;
assert_eq!(messages.len(), 2);
for (id, event) in ids.iter().zip(events) {
let message = messages
.iter()
.find(|message| message.id == *id)
.expect("same message ID after send");
assert!(!message.delivery_trace.outer_event_ids.is_empty());
assert_eq!(message.created_at_secs, authored_at.get());
assert_eq!(
core.app_store.load_outgoing_event(&chat_id, id).unwrap(),
Some(event)
);
}
}
#[test]
fn appcore_ready_direct_text_uses_same_queue_then_drain_path() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let mut core = logged_in_test_core("direct-ready-queue-drain", &owner, &device);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_current_device_appkeys_for_test(engine, &owner, &device);
engine
.ingest_app_keys_snapshot(
peer_owner.public_key(),
AppKeys::new(vec![DeviceEntry::new(peer_device.public_key(), 11)]),
11,
)
.expect("peer appkeys");
observe_peer_device_invite_for_test(engine, &peer_owner, &peer_device, 12);
}
let chat_id = peer_owner.public_key().to_hex();
core.send_direct_message(&chat_id, "ready hello", UnixSeconds(13), None);
let thread = core.threads.get(&chat_id).expect("thread after send");
assert_eq!(thread.messages.len(), 1);
let message = &thread.messages[0];
assert_ne!(message.id, "1", "temporary local id should be replaced");
assert_eq!(message.body, "ready hello");
assert!(!message.delivery_trace.outer_event_ids.is_empty());
assert!(!core.pending_relay_publishes.is_empty());
}
#[test]
fn appcore_direct_queue_rejects_genuinely_empty_content() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let mut core = logged_in_test_core("direct-empty-queue-rejected", &owner, &device);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_current_device_appkeys_for_test(engine, &owner, &device);
engine
.ingest_app_keys_snapshot(
peer_owner.public_key(),
AppKeys::new(vec![DeviceEntry::new(peer_device.public_key(), 11)]),
11,
)
.expect("peer appkeys");
observe_peer_device_invite_for_test(engine, &peer_owner, &peer_device, 12);
}
core.pending_relay_publishes.clear();
let chat_id = peer_owner.public_key().to_hex();
core.push_outgoing_message_with_id(
"empty-queued".to_string(),
&chat_id,
String::new(),
13,
None,
DeliveryState::Queued,
);
assert!(core.drain_queued_direct_text_messages("test_empty"));
let message = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("failed empty message");
assert_eq!(message.id, "empty-queued");
assert_eq!(message.delivery, DeliveryState::Failed);
assert!(core.pending_relay_publishes.values().all(|pending| {
pending.inner_event_id.as_deref() != Some("empty-queued")
}));
assert!(core.debug_log.iter().any(|entry| {
entry.category == "message.direct.queue.invalid"
&& entry.detail.contains("empty_content=true")
}));
}
#[test]
fn appcore_self_chat_sends_to_linked_sibling_without_waiting_on_current_device() {
let owner = Keys::generate();
let device = Keys::generate();
let sibling = Keys::generate();
let mut core = logged_in_test_core("self-chat-linked-sibling", &owner, &device);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_peer_device_invite_for_test(engine, &owner, &sibling, 2);
observe_peer_appkeys_for_test(
engine,
&owner,
&[device.public_key(), sibling.public_key()],
3,
);
}
let chat_id = owner.public_key().to_hex();
core.send_direct_message(&chat_id, "hello other me", UnixSeconds(4), None);
let message = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("self-chat message");
assert_ne!(
message.id, "1",
"self-chat must not stay queued waiting for the current device"
);
assert!(!message.delivery_trace.outer_event_ids.is_empty());
assert!(
!core.pending_relay_publishes.is_empty(),
"self-chat must publish an encrypted sender copy to linked siblings"
);
}
#[test]
fn session_start_retry_recovers_self_chat_queued_by_older_client() {
let owner = Keys::generate();
let device = Keys::generate();
let sibling = Keys::generate();
let mut core = logged_in_test_core("self-chat-startup-recovery", &owner, &device);
let chat_id = owner.public_key().to_hex();
core.push_outgoing_message_with_id(
"legacy-queued".to_string(),
&chat_id,
"recover me".to_string(),
3,
None,
DeliveryState::Queued,
);
{
let engine = core.protocol_engine.as_mut().expect("protocol engine");
observe_peer_device_invite_for_test(engine, &owner, &sibling, 4);
observe_peer_appkeys_for_test(
engine,
&owner,
&[device.public_key(), sibling.public_key()],
5,
);
}
core.retry_protocol_engine_pending_work("session_start");
let message = core
.threads
.get(&chat_id)
.and_then(|thread| thread.messages.first())
.expect("recovered self-chat message");
assert_ne!(message.id, "legacy-queued");
assert_eq!(message.body, "recover me");
assert!(!message.delivery_trace.outer_event_ids.is_empty());
assert!(!core.pending_relay_publishes.is_empty());
}
#[test]
fn group_fanout_retry_missing_roster_does_not_rewrite_persisted_state() {
assert_group_fanout_no_progress_does_not_rewrite_persisted_state(false);
}
#[test]
fn group_fanout_retry_missing_invite_does_not_rewrite_persisted_state() {
assert_group_fanout_no_progress_does_not_rewrite_persisted_state(true);
}
fn assert_group_fanout_no_progress_does_not_rewrite_persisted_state(known_roster: bool) {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let storage = Arc::new(CountingStorage::new());
let mut engine =
test_protocol_engine_with_storage(&owner, &device, storage.clone() as Arc<dyn StorageAdapter>);
observe_current_device_appkeys_for_test(&mut engine, &owner, &device);
if known_roster {
observe_peer_appkeys_for_test(
&mut engine,
&peer_owner,
&[Keys::generate().public_key()],
2,
);
}
let create = engine
.create_group(
"Queued group".to_string(),
vec![peer_owner.public_key()],
UnixSeconds(3),
)
.expect("create group");
assert!(create.effects.is_empty());
assert!(
engine.debug_snapshot().pending_group_fanout_count > 0,
"test must exercise durable pending group fanout retry"
);
let retry_now = unix_now().get();
let due_retry_at = retry_now.saturating_add(180);
let quiet_before = storage.put_count();
assert!(
!engine.has_due_pending_retry_work(NdrUnixSeconds(retry_now)),
"future-due group fanouts must not wake the protocol retry loop"
);
let quiet_batch = engine
.retry_pending_protocol(NdrUnixSeconds(retry_now))
.expect("retry before group fanout is due");
assert!(
quiet_batch.is_empty(),
"future-due group fanouts must not refresh protocol subscriptions"
);
assert_eq!(
storage.put_count(),
quiet_before,
"future-due group fanouts must not serialize unchanged ratchet state"
);
let generation_before_due = engine.debug_snapshot().subscription_generation;
let before = storage.put_count();
assert!(
engine.has_due_pending_retry_work(NdrUnixSeconds(due_retry_at)),
"due group fanouts should wake the protocol retry loop"
);
let batch = engine
.retry_pending_protocol(NdrUnixSeconds(due_retry_at))
.expect("retry unavailable group target");
assert!(
batch.is_empty(),
"unavailable-target group retries should remain pending without fetch/backfill effects"
);
assert_eq!(
storage.put_count(),
before,
"unavailable-target group retries must not serialize unchanged ratchet state"
);
let generation_after_due = engine.debug_snapshot().subscription_generation;
assert_eq!(
generation_after_due, generation_before_due,
"unavailable-target retries should not advance subscription generation without protocol output"
);
let quiet_after_due = storage.put_count();
let quiet_batch = engine
.retry_pending_protocol(NdrUnixSeconds(due_retry_at.saturating_add(1)))
.expect("retry before requeued group fanout is due");
assert!(
quiet_batch.is_empty(),
"requeued group fanouts must stay quiet until their next retry time"
);
assert_eq!(
engine.debug_snapshot().subscription_generation,
generation_after_due,
"quiet group retry checks must not churn subscription generations"
);
assert_eq!(
storage.put_count(),
quiet_after_due,
"quiet group retry checks must not serialize unchanged ratchet state"
);
let quiet_later = storage.put_count();
let quiet_batch = engine
.retry_pending_protocol(NdrUnixSeconds(due_retry_at.saturating_add(30)))
.expect("retry before stale group fanout backoff expires");
assert!(
quiet_batch.is_empty(),
"stale group fanouts must back off instead of polling every two seconds"
);
assert_eq!(
engine.debug_snapshot().subscription_generation,
generation_after_due,
"stale group fanout backoff must not churn subscription generations"
);
assert_eq!(
storage.put_count(),
quiet_later,
"stale group fanout backoff must not serialize unchanged ratchet state"
);
}
#[test]
fn appcore_direct_send_storage_failure_rolls_back_protocol_state() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let missing_peer_device = Keys::generate();
let storage = Arc::new(SwitchableFailStorage::new());
let mut engine = test_protocol_engine_with_storage(
&owner,
&device,
storage.clone() as Arc<dyn StorageAdapter>,
);
observe_current_device_appkeys_for_test(&mut engine, &owner, &device);
observe_peer_appkeys_for_test(
&mut engine,
&peer_owner,
&[peer_device.public_key(), missing_peer_device.public_key()],
1,
);
let mut rng = OsRng;
let mut ctx = ProtocolContext::new(NdrUnixSeconds(2), &mut rng);
let invite = Invite::create_new_with_context(
&mut ctx,
NdrDevicePubkey::from_bytes(peer_device.public_key().to_bytes()),
Some(NdrOwnerPubkey::from_bytes(
peer_owner.public_key().to_bytes(),
)),
None,
)
.expect("peer invite");
let invite_event = nostr_double_ratchet::invite_unsigned_event(&invite)
.expect("invite event")
.sign_with_keys(&peer_device)
.expect("signed invite");
engine
.observe_invite_event(&invite_event)
.expect("observe invite");
let before = engine.session_manager_snapshot_for_test();
storage.set_fail_puts(true);
let result = engine.send_direct_text(
peer_owner.public_key(),
&peer_owner.public_key().to_hex(),
"rollback",
None,
UnixSeconds(3),
);
assert!(result.is_err());
assert!(!engine.has_pending_retry_work(), "failed persistence must also roll back the partial remote queue");
assert_eq!(
engine.session_manager_snapshot_for_test(),
before,
"failed persistence must roll back in-memory ratchet state"
);
}
#[test]
fn appcore_group_create_storage_failure_rolls_back_protocol_state() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let storage = Arc::new(SwitchableFailStorage::new());
let mut engine = test_protocol_engine_with_storage(
&owner,
&device,
storage.clone() as Arc<dyn StorageAdapter>,
);
observe_current_device_appkeys_for_test(&mut engine, &owner, &device);
let before_sessions = engine.session_manager_snapshot_for_test();
let before_groups = engine.group_manager_snapshot_for_test();
storage.set_fail_puts(true);
let result = engine.create_group(
"rollback group".to_string(),
vec![peer_owner.public_key()],
UnixSeconds(3),
);
assert!(result.is_err());
assert_eq!(
engine.session_manager_snapshot_for_test(),
before_sessions,
"failed group persistence must roll back session fanout preparation"
);
assert_eq!(
engine.group_manager_snapshot_for_test(),
before_groups,
"failed group persistence must roll back group manager state"
);
assert_eq!(
engine.debug_snapshot().pending_group_fanout_count,
0,
"failed group persistence must not leave pending group fanouts in memory"
);
}
#[test]
fn appcore_direct_queue_delivers_to_ready_peer_while_other_peer_device_is_undiscovered() {
let owner = Keys::generate();
let device = Keys::generate();
let peer_owner = Keys::generate();
let peer_device = Keys::generate();
let missing_device = Keys::generate();
let mut sender = logged_in_test_core("partial-remote-sender", &owner, &device);
let mut receiver = logged_in_test_core("partial-remote-receiver", &peer_owner, &peer_device);
let own = signed_app_keys_authorization_event(&owner, device.public_key(), 1);
let peer = AppKeys::new(vec![
DeviceEntry::new(peer_device.public_key(), 1),
DeviceEntry::new(missing_device.public_key(), 1),
])
.get_event_at(peer_owner.public_key(), 1)
.sign_with_keys(&peer_owner)
.unwrap();
for core in [&mut sender, &mut receiver] {
core.handle_relay_event(own.clone());
core.handle_relay_event(peer.clone());
}
let invite = receiver
.protocol_engine
.as_ref()
.unwrap()
.local_invite()
.unwrap();
let invite_event = nostr_double_ratchet::invite_unsigned_event(&invite)
.unwrap()
.sign_with_keys(&peer_device)
.unwrap();
sender.handle_relay_event(invite_event);
let chat_id = peer_owner.public_key().to_hex();
sender.send_direct_message(
&chat_id,
"reply reaches the available device",
UnixSeconds(10),
None,
);
let sent = sender.threads[&chat_id].messages[0].clone();
assert!(
!sent.delivery_trace.outer_event_ids.is_empty(),
"ready peer must leave the unsent outbox immediately"
);
assert!(
sender
.protocol_engine
.as_ref()
.unwrap()
.has_pending_retry_work(),
"missing device retains a durable retry"
);
let plan = sender.compute_protocol_subscription_plan().unwrap();
assert!(
plan.invite_authors
.contains(&missing_device.public_key().to_hex()),
"missing device invite stays subscribed after partial send"
);
for kind in [INVITE_RESPONSE_KIND, MESSAGE_EVENT_KIND] {
for event in pending_events_with_kind(&sender, kind) {
receiver.handle_relay_event(event);
}
}
let received = &receiver.threads[&owner.public_key().to_hex()].messages[0];
assert_eq!(received.id, sent.id);
assert_eq!(received.body, "reply reaches the available device");
}