use super::{init_tracing, Tester}; use rust_lib_twonly::bridge::api::ApiConnectionState; use rust_lib_twonly::services::contacts::ContactService; use rust_lib_twonly::services::groups::GroupService; use rust_lib_twonly::services::messages::MessageService; async fn create_authenticated_tester() -> anyhow::Result { let mut tester = Tester::new().await?; tester.wait_until(ApiConnectionState::Connected).await?; tester.register_and_authenticate().await?; tester.wait_until(ApiConnectionState::Authenticated).await?; Ok(tester) } #[tokio::test] async fn test_group_membership_error_healing() -> anyhow::Result<()> { init_tracing(); let tester_a = create_authenticated_tester().await?; let tester_b = create_authenticated_tester().await?; // Tester A adds Tester B as contact ContactService::new(&tester_a.context) .request_by_username(tester_b.username.clone(), true) .await?; tester_b .wait_for_contact_state(tester_a.user_id, false, true) .await?; ContactService::new(&tester_b.context) .accept_request(tester_a.user_id, true) .await?; tester_a .wait_for_contact_state(tester_b.user_id, true, false) .await?; // Tester A creates group with Tester B let group_service_a = GroupService::new(&tester_a.context); let group_name = "Resilient Group"; group_service_a .create_group(group_name.into(), vec![tester_b.user_id]) .await?; let group_id = { let db_a = tester_a.context.app_db.read().await.clone(); sqlx::query_scalar!( "SELECT group_id FROM groups WHERE is_direct_chat = 0 ORDER BY rowid DESC LIMIT 1" ) .fetch_one(&db_a.pool) .await? }; tester_b .wait_for_group_exists(&group_id, group_name) .await?; // Tester B simulates local state wipe of this group (drops the group row) { let db_b = tester_b.context.app_db.read().await.clone(); sqlx::query!("DELETE FROM group_members WHERE group_id = ?", group_id) .execute(&db_b.pool) .await?; sqlx::query!("DELETE FROM groups WHERE group_id = ?", group_id) .execute(&db_b.pool) .await?; } // Tester A sends a text message in the group let msg_id = MessageService::new(&tester_a.context) .insert_and_send_text(group_id.clone(), "Message triggering heal".into(), None) .await?; // Tester B will report error, Tester A will heal and re-send GroupCreate, // and Tester B will rejoin and receive the message. tester_b .wait_for_group_exists(&group_id, group_name) .await?; // Wait a brief moment for group join to be acknowledged, then ensure queued receipts are retransmitted tokio::time::sleep(std::time::Duration::from_millis(500)).await; let _ = rust_lib_twonly::api::messages::incoming::messages::retransmit_queued_receipts( &tester_a.context, ) .await; tester_b .wait_for_text_message(&msg_id, tester_a.user_id, "Message triggering heal") .await?; Ok(()) } #[tokio::test] async fn test_add_hidden_contact() -> anyhow::Result<()> { init_tracing(); let tester_a = create_authenticated_tester().await?; let tester_b = create_authenticated_tester().await?; // Tester A adds Tester B as hidden contact GroupService::new(&tester_a.context) .add_hidden_contact(tester_b.user_id) .await?; let db_a = tester_a.context.app_db.read().await.clone(); let contact = sqlx::query!( "SELECT username, deleted_by_user FROM contacts WHERE user_id = ?", tester_b.user_id ) .fetch_one(&db_a.pool) .await?; assert_eq!(contact.username, tester_b.username); assert_eq!( contact.deleted_by_user, 1, "hidden contact must have deleted_by_user=1" ); Ok(()) } #[tokio::test] async fn test_admin_and_non_admin_leave_group() -> anyhow::Result<()> { init_tracing(); let tester_a = create_authenticated_tester().await?; let tester_b = create_authenticated_tester().await?; let tester_c = create_authenticated_tester().await?; // Setup contacts ContactService::new(&tester_a.context) .request_by_username(tester_b.username.clone(), true) .await?; tester_b .wait_for_contact_state(tester_a.user_id, false, true) .await?; ContactService::new(&tester_b.context) .accept_request(tester_a.user_id, true) .await?; tester_a .wait_for_contact_state(tester_b.user_id, true, false) .await?; ContactService::new(&tester_a.context) .request_by_username(tester_c.username.clone(), true) .await?; tester_c .wait_for_contact_state(tester_a.user_id, false, true) .await?; ContactService::new(&tester_c.context) .accept_request(tester_a.user_id, true) .await?; tester_a .wait_for_contact_state(tester_c.user_id, true, false) .await?; // Tester A creates group with Tester B and Tester C let group_name = "Leave Test Group"; GroupService::new(&tester_a.context) .create_group(group_name.into(), vec![tester_b.user_id, tester_c.user_id]) .await?; let group_id = { let db_a = tester_a.context.app_db.read().await.clone(); sqlx::query_scalar!( "SELECT group_id FROM groups WHERE is_direct_chat = 0 ORDER BY rowid DESC LIMIT 1" ) .fetch_one(&db_a.pool) .await? }; tester_b .wait_for_group_exists(&group_id, group_name) .await?; tester_c .wait_for_group_exists(&group_id, group_name) .await?; // Ask for anything the announcements did not deliver. Forced, so the // per-member request interval cannot skip it. { GroupService::new(&tester_a.context) .fetch_missing_group_public_keys(Some(group_id.clone()), true) .await?; tokio::time::sleep(std::time::Duration::from_millis(500)).await; } // Promote tester_b to admin so tester_a can leave later with an admin remaining GroupService::new(&tester_a.context) .manage_admin(group_id.clone(), tester_b.user_id, false) .await?; GroupService::new(&tester_b.context) .fetch_group_state(group_id.clone()) .await?; { let db_b = tester_b.context.app_db.read().await.clone(); let is_admin = sqlx::query_scalar!( "SELECT is_group_admin FROM groups WHERE group_id = ?", group_id ) .fetch_one(&db_b.pool) .await?; assert_eq!(is_admin, 1, "tester_b should be admin after promotion"); } // 1. Non-admin (tester_c) leaves the group GroupService::new(&tester_c.context) .leave_group(group_id.clone()) .await?; tester_c.wait_for_group_left(&group_id).await?; // Verify tester_a and tester_b see tester_c removed GroupService::new(&tester_a.context) .fetch_group_state(group_id.clone()) .await?; tester_a .wait_for_group_member_removed(&group_id, tester_c.user_id) .await?; // 2. Admin (tester_a) leaves the group using leave_group GroupService::new(&tester_a.context) .leave_group(group_id.clone()) .await?; tester_a.wait_for_group_left(&group_id).await?; // Verify tester_b (remaining admin) sees tester_a removed and tester_b remains admin GroupService::new(&tester_b.context) .fetch_group_state(group_id.clone()) .await?; tester_b .wait_for_group_member_removed(&group_id, tester_a.user_id) .await?; { let db_b = tester_b.context.app_db.read().await.clone(); let is_admin = sqlx::query_scalar!( "SELECT is_group_admin FROM groups WHERE group_id = ?", group_id ) .fetch_one(&db_b.pool) .await?; assert_eq!(is_admin, 1, "tester_b should still be admin"); } Ok(()) }