twonly-app/rust/tests/api/stories.rs
2026-09-27 21:27:52 +02:00

409 lines
14 KiB
Rust

use super::{init_tracing, Tester};
use prost::Message as _;
use rust_lib_twonly::api::messages::outgoing::send_c2c_message_to_contact;
use rust_lib_twonly::api::proto::client::{encrypted_content, EncryptedContent};
use rust_lib_twonly::bridge::api::ApiConnectionState;
use rust_lib_twonly::database::app::tables::Group;
use rust_lib_twonly::services::contacts::ContactService;
use rust_lib_twonly::services::media_upload::MediaUploadService;
use rust_lib_twonly::services::messages::MessageService;
use rust_lib_twonly::services::stories::{self, StoryAudience, STORY_LIFETIME_SECONDS};
use tokio::time::{sleep, Duration};
async fn create_authenticated_tester() -> anyhow::Result<Tester> {
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)
}
async fn connect(requester: &Tester, accepter: &Tester) -> anyhow::Result<()> {
ContactService::new(&requester.context)
.request_by_username(accepter.username.clone(), true)
.await?;
accepter
.wait_for_contact_state(requester.user_id, false, true)
.await?;
ContactService::new(&accepter.context)
.accept_request(requester.user_id, true)
.await?;
requester
.wait_for_contact_state(accepter.user_id, true, false)
.await?;
Ok(())
}
async fn scalar(tester: &Tester, sql: &str, bind: &str) -> anyhow::Result<i64> {
let database = tester.context.app_db.read().await.clone();
Ok(
sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(sql.to_owned()))
.bind(bind)
.fetch_one(&database.pool)
.await?,
)
}
/// Polls until `sql` (with one bound value) returns something other than 0.
async fn wait_for(tester: &Tester, what: &str, sql: &str, bind: &str) -> anyhow::Result<()> {
for _ in 0..100 {
if scalar(tester, sql, bind).await? != 0 {
return Ok(());
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow::anyhow!("{what} did not happen"))
}
/// Files a story row for `recipient` on `sender`, as `insert_into_messages`
/// does, and sends the envelope `prepare_media` builds for it. Only the
/// envelope travels: on this host a media upload stops before the native
/// transfer, so the attachment itself never reaches the server.
async fn post_story(
sender: &Tester,
recipient: &Tester,
message_id: &str,
media_id: &str,
posted_at: i64,
notify: bool,
) -> anyhow::Result<()> {
let group_id = Group::direct_chat_id(sender.user_id, recipient.user_id);
{
let database = sender.context.app_db.read().await.clone();
sqlx::query(
"INSERT OR IGNORE INTO media_files(media_id, type, upload_state) VALUES (?, 'image', 'uploaded')",
)
.bind(media_id)
.execute(&database.pool)
.await?;
sqlx::query(
"INSERT INTO messages(group_id, message_id, type, media_id, is_story, created_at) VALUES (?, ?, 'media', ?, 1, ?)",
)
.bind(&group_id)
.bind(message_id)
.bind(media_id)
.bind(posted_at)
.execute(&database.pool)
.await?;
}
let content = EncryptedContent {
story: Some(encrypted_content::Story {
media: Some(encrypted_content::Media {
sender_message_id: message_id.to_owned(),
r#type: encrypted_content::media::Type::Image as i32,
timestamp: posted_at * 1_000,
download_token: Some(vec![1; 32]),
encryption_key: Some(vec![2; 32]),
encryption_mac: Some(vec![3; 16]),
encryption_nonce: Some(vec![4; 12]),
..Default::default()
}),
notify,
}),
..Default::default()
};
send_c2c_message_to_contact()
.ctx(&sender.context)
.contact_id(recipient.user_id)
.encrypted_content(content.encode_to_vec())
.message_id(message_id.to_owned())
.call()
.await?;
Ok(())
}
const STORY_ROW: &str = "SELECT COUNT(*) FROM messages WHERE message_id = ? AND is_story = 1";
const STORY_OUTBOX: &str =
"SELECT COUNT(*) FROM notification_outbox WHERE kind = 'story' AND message_id = ?";
/// A posts to B and to C, who muted story notifications. Everything below
/// crosses the dev server between three real clients.
#[tokio::test]
async fn test_story_lifecycle_between_clients() -> 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?;
tester_c.set_story_notifications(false)?;
connect(&tester_a, &tester_b).await?;
connect(&tester_a, &tester_c).await?;
let chat_ab = Group::direct_chat_id(tester_a.user_id, tester_b.user_id);
let chat_ac = Group::direct_chat_id(tester_a.user_id, tester_c.user_id);
//
// 1. The real send path: "All" becomes one hidden row per contact, and
// preparation encrypts one envelope per recipient.
//
let uploads = MediaUploadService::new(&tester_a.context);
let media_id = uploads.initialize("image".into(), None, false).await?;
let plaintext_path = std::path::PathBuf::from(&tester_a.context.config.data_dir)
.join("mediafiles/tmp")
.join(format!("{media_id}.webp"));
std::fs::create_dir_all(plaintext_path.parent().expect("tmp directory"))?;
let source = image::RgbImage::from_pixel(8, 6, image::Rgb([12, 34, 56]));
let plaintext = webp::Encoder::from_rgb(source.as_raw(), source.width(), source.height())
.encode_simple(false, 80.0)
.map_err(|error| anyhow::anyhow!("could not encode media fixture: {error:?}"))?;
std::fs::write(&plaintext_path, plaintext.as_ref())?;
let all = StoryAudience {
all: true,
contact_group_ids: Vec::new(),
};
// A widget send cannot also be a story.
assert!(uploads
.insert_into_messages(media_id.clone(), Vec::new(), None, true, Some(all.clone()))
.await
.is_err());
uploads
.insert_into_messages(media_id.clone(), Vec::new(), None, false, Some(all))
.await?;
{
let database = tester_a.context.app_db.read().await.clone();
let mut groups: Vec<String> = sqlx::query_scalar(
"SELECT group_id FROM messages WHERE media_id = ? AND is_story = 1 AND sender_id IS NULL",
)
.bind(&media_id)
.fetch_all(&database.pool)
.await?;
groups.sort();
let mut expected = vec![chat_ab.clone(), chat_ac.clone()];
expected.sort();
assert_eq!(groups, expected, "one hidden story row per contact");
}
tester_a.wait_for_media_encryption_mac(&media_id).await?;
wait_for(
&tester_a,
"an envelope for every story recipient",
"SELECT COUNT(*) = 2 FROM receipts r JOIN messages m ON m.message_id = r.message_id
WHERE m.media_id = ? AND r.will_be_retried_by_media_upload = 1",
&media_id,
)
.await?;
tester_a.wait_for_no_pending_upload_jobs().await?;
// Still uploading, so it cannot be taken down yet.
assert!(stories::delete_item(&tester_a.context, &media_id)
.await
.is_err());
//
// 2. A story is announced only between people who wrote to each other
// in the last two weeks. A and B have not yet: filed, not announced.
//
let now = chrono::Utc::now().timestamp();
post_story(&tester_a, &tester_b, "s-quiet-b", "s-quiet", now, true).await?;
wait_for(
&tester_b,
"the quiet story to be filed",
STORY_ROW,
"s-quiet-b",
)
.await?;
assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s-quiet-b").await?, 0);
// One text is enough; C, who muted stories, gets one as well.
for (recipient, text) in [(&tester_b, "hi b"), (&tester_c, "hi c")] {
let chat = Group::direct_chat_id(tester_a.user_id, recipient.user_id);
let text_id = MessageService::new(&tester_a.context)
.insert_and_send_text(chat, text.into(), None, None)
.await?;
recipient
.wait_for_text_message(&text_id, tester_a.user_id, text)
.await?;
}
//
// 3. Only the item the sender woke for is announced, and a muted
// receiver files stories without announcing any.
//
for (recipient, suffix) in [(&tester_b, "b"), (&tester_c, "c")] {
post_story(
&tester_a,
recipient,
&format!("s1-{suffix}"),
"s1",
now,
true,
)
.await?;
post_story(
&tester_a,
recipient,
&format!("s2-{suffix}"),
"s2",
now,
false,
)
.await?;
}
for (recipient, suffix) in [(&tester_b, "b"), (&tester_c, "c")] {
for item in ["s1", "s2"] {
let message_id = format!("{item}-{suffix}");
wait_for(recipient, "the story to be filed", STORY_ROW, &message_id).await?;
}
}
tester_b
.wait_for_notification("story", tester_a.user_id)
.await?;
assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s1-b").await?, 1);
assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s2-b").await?, 0);
assert_eq!(scalar(&tester_c, STORY_OUTBOX, "s1-c").await?, 0);
// Filed under the direct chat, and not part of it.
assert_eq!(
scalar(
&tester_b,
"SELECT COUNT(*) FROM messages WHERE group_id = ? AND is_story = 1",
&chat_ab
)
.await?,
3
);
//
// 4. A story that arrives after its 24 hours is ignored without a receipt;
// the sender's purge is what stops it being retried.
//
let expired = now - STORY_LIFETIME_SECONDS - 60;
post_story(&tester_a, &tester_b, "s0-b", "s0", expired, true).await?;
post_story(&tester_a, &tester_b, "s3-b", "s3", now, false).await?;
wait_for(&tester_b, "the later story to be filed", STORY_ROW, "s3-b").await?;
wait_for(
&tester_a,
"the later story to be acknowledged",
"SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'ackByUserAt'",
"s3-b",
)
.await?;
assert_eq!(
scalar(
&tester_b,
"SELECT COUNT(*) FROM messages WHERE message_id = ?",
"s0-b"
)
.await?,
0
);
assert_eq!(
scalar(
&tester_a,
"SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'ackByUserAt'",
"s0-b"
)
.await?,
0,
"an expired story is never acknowledged"
);
assert_eq!(
scalar(
&tester_a,
"SELECT COUNT(*) FROM receipts WHERE message_id = ?",
"s0-b"
)
.await?,
1
);
stories::purge_expired(&tester_a.context).await?;
assert_eq!(
scalar(
&tester_a,
"SELECT COUNT(*) FROM receipts WHERE message_id = ?",
"s0-b"
)
.await?,
0
);
assert_eq!(
scalar(
&tester_a,
"SELECT COUNT(*) FROM messages WHERE message_id = ?",
"s0-b"
)
.await?,
0
);
//
// 5. What a viewer does comes back to the sender: opening, saving, and a
// reaction as a message quoting the story.
//
MessageService::new(&tester_b.context)
.notify_opened(tester_a.user_id, vec!["s1-b".into()])
.await?;
wait_for(
&tester_a,
"B's view to be recorded",
"SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'openedAt'",
"s1-b",
)
.await?;
// Opening the story clears its announcement on B.
assert_eq!(
scalar(
&tester_b,
"SELECT COUNT(*) FROM notification_outbox WHERE message_id = ? AND cleared_at IS NULL",
"s1-b"
)
.await?,
0
);
let stored = EncryptedContent {
media_update: Some(encrypted_content::MediaUpdate {
r#type: encrypted_content::media_update::Type::Stored as i32,
target_message_id: "s1-b".into(),
}),
..Default::default()
};
send_c2c_message_to_contact()
.ctx(&tester_b.context)
.contact_id(tester_a.user_id)
.encrypted_content(stored.encode_to_vec())
.call()
.await?;
tester_a.wait_for_media_stored("s1-b").await?;
// Each of these reaches A worded for what it is about: A's story.
tester_a
.wait_for_notification("stored_story", tester_b.user_id)
.await?;
let reaction_id = MessageService::new(&tester_b.context)
.insert_and_send_text(chat_ab.clone(), "🔥".into(), Some("s1-b".into()), None)
.await?;
tester_a
.wait_for_quoted_text_message(&reaction_id, tester_b.user_id, "🔥", "s1-b")
.await?;
let reaction = tester_a
.wait_for_notification("story_reaction", tester_b.user_id)
.await?;
assert_eq!(reaction.body, "has reacted with 🔥 to your story.");
let reply_id = MessageService::new(&tester_b.context)
.insert_and_send_text(chat_ab.clone(), "love it".into(), Some("s1-b".into()), None)
.await?;
tester_a
.wait_for_quoted_text_message(&reply_id, tester_b.user_id, "love it", "s1-b")
.await?;
let reply = tester_a
.wait_for_notification("story_reply", tester_b.user_id)
.await?;
assert_eq!(reply.body, "has replied to your story.");
//
// 6. Taking an item down removes it for every recipient.
//
stories::delete_item(&tester_a.context, "s2").await?;
tester_b.wait_for_message_deleted("s2-b").await?;
tester_c.wait_for_message_deleted("s2-c").await?;
assert_eq!(
scalar(
&tester_a,
"SELECT COUNT(*) FROM messages WHERE media_id = ?",
"s2"
)
.await?,
0
);
Ok(())
}