/* * Copyright (c) 2026, Tobias Müller git@tsmr.eu * */ use sqlx::{Sqlite, Transaction}; use std::collections::{HashMap, HashSet}; use crate::context::Context; use crate::error::{Result, TwonlyError}; use crate::utils::{current_time, start_of_local_day}; use super::Contact; const MAX_FUTURE_TIMESTAMP_SKEW_SECONDS: i64 = 10 * 60; pub struct Group; impl Group { pub async fn flame_sync_candidates(pool: &sqlx::Pool) -> Result> { Ok(sqlx::query_as!( FlameSyncGroup, r#"SELECT group_id, total_media_counter, last_flame_counter_change, last_flame_sync FROM groups WHERE last_flame_counter_change IS NOT NULL"# ) .fetch_all(pool) .await?) } /// The chat the user exchanges the most media with. Derived rather than /// stored: it is a pure function of `total_media_counter`, and persisting it /// in the user configuration would leave Flutter's in-memory copy stale, /// because nothing tells Dart when Rust rewrites that file. pub async fn best_friend_group_id(pool: &sqlx::Pool) -> Result> { Ok(sqlx::query_scalar!( "SELECT group_id FROM groups ORDER BY total_media_counter DESC LIMIT 1" ) .fetch_optional(pool) .await?) } /// The flame counter as it is shown, which is not what the column holds: a /// streak survives in the database until the next media exchange rewrites /// it, so whether it still counts has to be decided against today. /// /// Passing `None` derives the state of every group; the best friend is /// resolved once either way. pub async fn flame_states( pool: &sqlx::Pool, group_ids: Option>, ) -> Result> { let rows = sqlx::query_as!( FlameRow, r#"SELECT group_id, flame_counter, also_best_friend, last_message_send, last_message_received, last_flame_counter_change FROM groups"# ) .fetch_all(pool) .await?; let best_friend = Self::best_friend_group_id(pool).await?; let start_of_today = start_of_local_day()?; let wanted = group_ids.map(|ids| ids.into_iter().collect::>()); Ok(rows .into_iter() .filter(|row| { wanted .as_ref() .is_none_or(|ids| ids.contains(&row.group_id)) }) .map(|row| { let is_best_friend = row.also_best_friend != 0 && best_friend.as_deref() == Some(row.group_id.as_str()); let state = row.derive(start_of_today, is_best_friend); (row.group_id, state) }) .collect()) } pub async fn flame_state(pool: &sqlx::Pool, group_id: &str) -> Result { Ok(Self::flame_states(pool, Some(vec![group_id.to_string()])) .await? .remove(group_id) .unwrap_or_default()) } /// Whether the "restore flames" offer makes sense: a streak worth restoring /// was lost recently enough that the user still remembers having it. pub async fn can_restore_flames(pool: &sqlx::Pool, group_id: &str) -> Result { let Some(group) = sqlx::query!( "SELECT max_flame_counter, max_flame_counter_from FROM groups WHERE group_id = ?", group_id, ) .fetch_optional(pool) .await? else { return Ok(false); }; let counter = Self::flame_state(pool, group_id).await?.counter; Ok(can_restore( group.max_flame_counter, group.max_flame_counter_from, counter, current_time().timestamp(), )) } /// Puts the lost streak back and marks today as exchanged in both /// directions, so the restored counter survives the next derivation. /// Returns the restored value, or `None` when there was nothing to restore. pub async fn restore_flames( tr: &mut Transaction<'_, Sqlite>, group_id: &str, ) -> Result> { let Some(group) = sqlx::query!( r#"SELECT flame_counter, max_flame_counter, max_flame_counter_from, last_message_send, last_message_received, last_flame_counter_change FROM groups WHERE group_id = ?"#, group_id, ) .fetch_optional(&mut **tr) .await? else { return Ok(None); }; let now = current_time().timestamp(); let state = FlameRow { group_id: group_id.to_string(), flame_counter: group.flame_counter, also_best_friend: 0, last_message_send: group.last_message_send, last_message_received: group.last_message_received, last_flame_counter_change: group.last_flame_counter_change, } .derive(start_of_local_day()?, false); if !can_restore( group.max_flame_counter, group.max_flame_counter_from, state.counter, now, ) { return Ok(None); } sqlx::query!( r#"UPDATE groups SET flame_counter = ?, last_flame_counter_change = ?, last_message_send = ?, last_message_received = ? WHERE group_id = ?"#, group.max_flame_counter, now, now, now, group_id, ) .execute(&mut **tr) .await?; Ok(Some(group.max_flame_counter)) } pub async fn set_last_flame_sync( pool: &sqlx::Pool, group_id: &str, timestamp: i64, ) -> Result<()> { sqlx::query!( "UPDATE groups SET last_flame_sync = ? WHERE group_id = ?", timestamp, group_id, ) .execute(pool) .await?; Ok(()) } /// Records one successfully created media message and updates the flame /// state in the same transaction. This used to round-trip through Dart, /// which made the counters non-atomic and unavailable to headless Rust /// runtimes. pub async fn record_media_exchange( tr: &mut Transaction<'_, Sqlite>, group_id: &str, received: bool, timestamp: i64, ) -> Result<()> { let Some(group) = sqlx::query!( r#"SELECT last_message_send, last_message_received, last_flame_counter_change, flame_counter, max_flame_counter, max_flame_counter_from FROM groups WHERE group_id = ?"#, group_id, ) .fetch_optional(&mut **tr) .await? else { return Ok(()); }; if received { sqlx::query!( r#"UPDATE contacts SET media_received_counter = media_received_counter + 1 WHERE user_id IN ( SELECT contact_id FROM group_members WHERE group_id = ? )"#, group_id, ) .execute(&mut **tr) .await?; } else { sqlx::query!( r#"UPDATE contacts SET media_send_counter = media_send_counter + 1 WHERE user_id IN ( SELECT contact_id FROM group_members WHERE group_id = ? )"#, group_id, ) .execute(&mut **tr) .await?; } let now = current_time(); let start_of_today = start_of_local_day()?; let two_days_ago = start_of_today - 2 * 24 * 60 * 60; let mut flame_counter = group.flame_counter; let mut max_flame_counter = group.max_flame_counter; let mut max_flame_counter_from = group.max_flame_counter_from; let mut last_flame_counter_change = group.last_flame_counter_change; if group .last_message_send .zip(group.last_message_received) .is_some_and(|(sent, received)| sent < two_days_ago || received < two_days_ago) { flame_counter = 0; } if last_flame_counter_change.is_none_or(|changed| changed < start_of_today) { let completes_today = if received { group .last_message_send .is_some_and(|sent| sent > start_of_today) } else { group .last_message_received .is_some_and(|received| received > start_of_today) }; if completes_today { flame_counter += 1; if last_flame_counter_change.is_none_or(|changed| changed < timestamp) { last_flame_counter_change = Some(timestamp); } if flame_counter >= max_flame_counter || max_flame_counter_from .is_none_or(|from| from < now.timestamp() - 5 * 24 * 60 * 60) { max_flame_counter = flame_counter; max_flame_counter_from = Some(now.timestamp()); } } } let last_message_send = if !received && group .last_message_send .is_none_or(|previous| previous < timestamp) { Some(timestamp) } else { group.last_message_send }; let last_message_received = if received && group .last_message_received .is_none_or(|previous| previous < timestamp) { Some(timestamp) } else { group.last_message_received }; sqlx::query!( r#"UPDATE groups SET total_media_counter = total_media_counter + 1, last_flame_counter_change = ?, last_message_received = ?, last_message_send = ?, flame_counter = ?, max_flame_counter = ?, max_flame_counter_from = ? WHERE group_id = ?"#, last_flame_counter_change, last_message_received, last_message_send, flame_counter, max_flame_counter, max_flame_counter_from, group_id, ) .execute(&mut **tr) .await?; Ok(()) } pub async fn ensure_exists(tr: &mut Transaction<'_, Sqlite>, group_id: &str) -> Result<()> { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?)", group_id ) .fetch_one(&mut **tr) .await? != 0; if !exists { return Err(TwonlyError::Generic(format!( "group {group_id} does not exist" ))); } Ok(()) } pub async fn create_direct_chat( ctx: &Context, tr: &mut Transaction<'_, Sqlite>, contact: Contact, ) -> Result<()> { let local_user_id = ctx.user_id().await?; let contact_id = contact.user_id; let group_id = Self::direct_chat_id(local_user_id, contact_id); let group_name = contact.get_group_name(); sqlx::query!( r#" INSERT INTO groups(group_id, group_name, is_direct_chat, is_group_admin, joined_group) VALUES (?, ?, 1, 1, 1) ON CONFLICT(group_id) DO UPDATE SET joined_group = 1 "#, group_id, group_name, ) .execute(&mut **tr) .await?; sqlx::query!( "INSERT OR IGNORE INTO group_members(group_id, contact_id) VALUES (?, ?)", group_id, contact_id, ) .execute(&mut **tr) .await?; Ok(()) } pub fn direct_chat_id(a: i64, b: i64) -> String { let (high, low) = if a >= b { (a as u64, b as u64) } else { (b as u64, a as u64) }; let hex = format!("{high:016x}{low:016x}"); format!( "{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32] ) } pub async fn is_direct_chat(tr: &mut Transaction<'_, Sqlite>, group_id: &str) -> Result { let is_direct = sqlx::query_scalar!( "SELECT is_direct_chat FROM groups WHERE group_id = ?", group_id ) .fetch_optional(&mut **tr) .await? .unwrap_or(0) != 0; Ok(is_direct) } /// Whether this member is recorded as having left the group. /// /// [`Self::is_member`] deliberately ignores `member_state` so a member who /// left can still be seen to have written; the outgoing fan-out does not. /// This is the seam between the two, for callers that need to know the /// directions disagree. pub async fn has_member_left( tr: &mut Transaction<'_, Sqlite>, group_id: &str, contact_id: i64, ) -> Result { Ok(sqlx::query_scalar!( r#" SELECT EXISTS( SELECT 1 FROM group_members WHERE group_id = ? AND contact_id = ? AND member_state = 'leftGroup' ) "#, group_id, contact_id, ) .fetch_one(&mut **tr) .await? != 0) } pub async fn is_member( tr: &mut Transaction<'_, Sqlite>, group_id: &str, contact_id: i64, ) -> Result { let is_member = sqlx::query_scalar!( r#" SELECT EXISTS( SELECT 1 FROM group_members WHERE group_id = ? AND contact_id = ? ) "#, group_id, contact_id, ) .fetch_one(&mut **tr) .await? != 0; Ok(is_member) } /// Notes a text or media message in this chat, sent or received. This is /// what "these two still talk" is measured by; see /// `stories::exchanged_recently`. pub async fn record_text_or_media( tr: &mut Transaction<'_, Sqlite>, group_id: &str, timestamp: i64, ) -> Result<()> { let now = current_unix_timestamp()?; let timestamp = timestamp.min(now.saturating_add(MAX_FUTURE_TIMESTAMP_SKEW_SECONDS)); sqlx::query!( r#"UPDATE groups SET last_text_or_media_at = MAX(COALESCE(last_text_or_media_at, 0), ?) WHERE group_id = ?"#, timestamp, group_id, ) .execute(&mut **tr) .await?; Ok(()) } pub async fn increase_last_message_exchange_to_now( tr: &mut Transaction<'_, Sqlite>, group_id: &str, ) -> Result<()> { let now = current_unix_timestamp()?; Self::increase_last_message_exchange(tr, group_id, now).await } pub async fn increase_last_message_exchange( tr: &mut Transaction<'_, Sqlite>, group_id: &str, timestamp: i64, ) -> Result<()> { let now = current_unix_timestamp()?; let timestamp = timestamp.min(now.saturating_add(MAX_FUTURE_TIMESTAMP_SKEW_SECONDS)); sqlx::query!( r#" UPDATE groups SET last_message_exchange = MAX(last_message_exchange, ?), deleted_content = 0 WHERE group_id = ? "#, timestamp, group_id, ) .execute(&mut **tr) .await?; Ok(()) } } pub struct FlameSyncGroup { pub group_id: String, pub total_media_counter: i64, pub last_flame_counter_change: Option, pub last_flame_sync: Option, } /// The flame counter as the UI shows it. Everything here is derived from the /// group row against the current local day, never read straight out of a column. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] pub struct FlameState { pub counter: i64, /// No exchange yet today, so the streak ends at midnight. pub is_expiring: bool, /// Mutual: our most-exchanged chat, and they said the same about us. pub is_best_friend: bool, } struct FlameRow { group_id: String, flame_counter: i64, also_best_friend: i64, last_message_send: Option, last_message_received: Option, last_flame_counter_change: Option, } impl FlameRow { fn derive(&self, start_of_today: i64, is_best_friend: bool) -> FlameState { let expired = FlameState { is_best_friend, ..FlameState::default() }; let (Some(sent), Some(received), Some(changed)) = ( self.last_message_send, self.last_message_received, self.last_flame_counter_change, ) else { return expired; }; let two_days_ago = start_of_today - 2 * 24 * 60 * 60; let one_day_ago = start_of_today - 24 * 60 * 60; if sent > two_days_ago && received > two_days_ago || changed > one_day_ago { FlameState { counter: self.flame_counter, is_expiring: sent < one_day_ago || received < one_day_ago, is_best_friend, } } else { expired } } } #[derive(bon::Builder)] pub struct InsertGroup { group_id: String, group_name: String, state_encryption_key: Vec, my_group_private_key: Vec, } impl InsertGroup { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { sqlx::query!( r#" INSERT INTO groups( group_id, group_name, is_group_admin, state_encryption_key, state_version_id, my_group_private_key, joined_group ) VALUES (?, ?, 1, ?, 1, ?, 1) "#, self.group_id, self.group_name, self.state_encryption_key, self.my_group_private_key ) .execute(&mut **tr) .await?; Ok(()) } } #[derive(bon::Builder)] pub struct UpdateGroup { group_id: String, group_name: Option, delete_messages_after_milliseconds: Option>, is_group_admin: Option, joined_group: Option, left_group: Option, state_version_id: Option, } impl UpdateGroup { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { let is_group_admin = self.is_group_admin.map(|v| v as i64); let joined_group = self.joined_group.map(|v| v as i64); let left_group = self.left_group.map(|v| v as i64); let state_version_id = self.state_version_id.map(|v| v as i64); // Note: For partial updates, it's easier to coalesce in SQL, but sqlx query! requires static queries. // If we want to dynamically update, we might need a specific query for apply_state and others. // For apply_state: if let ( Some(group_name), Some(delete_ms), Some(is_admin), Some(joined), Some(left), Some(version), ) = ( self.group_name, self.delete_messages_after_milliseconds, is_group_admin, joined_group, left_group, state_version_id, ) { sqlx::query!( r#" UPDATE groups SET group_name = ?, delete_messages_after_milliseconds = COALESCE(?, delete_messages_after_milliseconds), is_group_admin = ?, joined_group = ?, left_group = ?, state_version_id = ? WHERE group_id = ? "#, group_name, delete_ms, is_admin, joined, left, version, self.group_id ) .execute(&mut **tr) .await?; } else if let Some(left_group) = left_group { sqlx::query!( "UPDATE groups SET left_group = ? WHERE group_id = ?", left_group, self.group_id ) .execute(&mut **tr) .await?; } Ok(()) } } #[derive(bon::Builder)] pub struct InsertGroupMember { group_id: String, contact_id: i64, member_state: String, #[builder(default)] on_conflict_update: bool, } impl InsertGroupMember { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { if self.on_conflict_update { sqlx::query!( r#" INSERT INTO group_members(group_id, contact_id, member_state) VALUES (?, ?, ?) ON CONFLICT(group_id, contact_id) DO UPDATE SET member_state = excluded.member_state "#, self.group_id, self.contact_id, self.member_state ) .execute(&mut **tr) .await?; } else { sqlx::query!( "INSERT INTO group_members(group_id, contact_id, member_state) VALUES (?, ?, ?)", self.group_id, self.contact_id, self.member_state ) .execute(&mut **tr) .await?; } Ok(()) } } #[derive(bon::Builder)] pub struct DeleteGroupMembers { group_id: String, } impl DeleteGroupMembers { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { sqlx::query!( "DELETE FROM group_members WHERE group_id = ?", self.group_id ) .execute(&mut **tr) .await?; Ok(()) } } #[derive(bon::Builder)] pub struct UpdateGroupMemberState { group_id: String, member_state: String, } impl UpdateGroupMemberState { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { sqlx::query!( "UPDATE group_members SET member_state = ? WHERE group_id = ?", self.member_state, self.group_id ) .execute(&mut **tr) .await?; Ok(()) } } #[derive(bon::Builder)] pub struct GetGroupPublicKey { group_id: String, contact_id: i64, } impl GetGroupPublicKey { pub async fn fetch(self, tr: &mut Transaction<'_, Sqlite>) -> Result>> { let key = sqlx::query_scalar!( "SELECT group_public_key FROM group_members WHERE group_id = ? AND contact_id = ?", self.group_id, self.contact_id ) .fetch_optional(&mut **tr) .await? .flatten(); Ok(key) } // For pool query compatibility pub async fn fetch_pool(self, pool: &sqlx::Pool) -> Result>> { let key = sqlx::query_scalar!( "SELECT group_public_key FROM group_members WHERE group_id = ? AND contact_id = ?", self.group_id, self.contact_id ) .fetch_optional(pool) .await? .flatten(); Ok(key) } } #[derive(bon::Builder)] pub struct UpdateGroupPublicKey { group_id: String, contact_id: i64, group_public_key: Vec, } impl UpdateGroupPublicKey { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { sqlx::query!( "UPDATE group_members SET group_public_key = ? WHERE group_id = ? AND contact_id = ?", self.group_public_key, self.group_id, self.contact_id ) .execute(&mut **tr) .await?; Ok(()) } } #[derive(bon::Builder)] pub struct InsertGroupHistory { group_id: String, affected_contact_id: Option, new_group_name: Option, new_delete_messages_after_milliseconds: Option, r#type: String, } impl InsertGroupHistory { pub async fn execute(self, tr: &mut Transaction<'_, Sqlite>) -> Result<()> { let history_id = crate::utils::new_uuid_v4(); sqlx::query!( r#" INSERT INTO group_histories( group_history_id, group_id, affected_contact_id, new_group_name, new_delete_messages_after_milliseconds, type ) VALUES (?, ?, ?, ?, ?, ?) "#, history_id, self.group_id, self.affected_contact_id, self.new_group_name, self.new_delete_messages_after_milliseconds, self.r#type ) .execute(&mut **tr) .await?; Ok(()) } } #[derive(bon::Builder)] pub struct GetUnjoinedGroups {} impl GetUnjoinedGroups { pub async fn fetch_all( self, pool: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> Result> { let ids = sqlx::query_scalar!( "SELECT group_id FROM groups WHERE joined_group = 0 AND left_group = 0" ) .fetch_all(pool) .await?; Ok(ids) } } pub struct MissingGroupPublicKeyRow { pub group_id: String, pub contact_id: i64, } /// Lists the members whose group public key is still unknown. /// /// Direct chats are excluded: they never carry a group identity, so the peer /// has no key to resend and would answer every request with an error. Left /// groups and members who left are excluded for the same reason -- their key /// is of no use any more. #[derive(bon::Builder)] pub struct GetMissingGroupPublicKeys { group_id: Option, } impl GetMissingGroupPublicKeys { pub async fn fetch_all( self, pool: &sqlx::Pool, ) -> Result> { let group_id = self.group_id.as_deref(); let rows = sqlx::query_as!( MissingGroupPublicKeyRow, r#" SELECT members.group_id, members.contact_id FROM group_members AS members JOIN groups ON groups.group_id = members.group_id WHERE members.group_public_key IS NULL AND (members.member_state IS NULL OR members.member_state != 'leftGroup') AND groups.is_direct_chat = 0 AND groups.left_group = 0 AND (? IS NULL OR members.group_id = ?) "#, group_id, group_id, ) .fetch_all(pool) .await?; Ok(rows) } pub async fn fetch_all_in_transaction( self, tr: &mut Transaction<'_, Sqlite>, ) -> Result> { let group_id = self.group_id.as_deref(); let rows = sqlx::query_as!( MissingGroupPublicKeyRow, r#" SELECT members.group_id, members.contact_id FROM group_members AS members JOIN groups ON groups.group_id = members.group_id WHERE members.group_public_key IS NULL AND (members.member_state IS NULL OR members.member_state != 'leftGroup') AND groups.is_direct_chat = 0 AND groups.left_group = 0 AND (? IS NULL OR members.group_id = ?) "#, group_id, group_id, ) .fetch_all(&mut **tr) .await?; Ok(rows) } } fn current_unix_timestamp() -> Result { Ok(crate::utils::current_time().timestamp()) } /// A streak is worth offering back while it was big enough to miss and recent /// enough to still be remembered. Pure so the boundaries stay testable without /// a controllable clock. fn can_restore( max_flame_counter: i64, max_flame_counter_from: Option, counter: i64, now: i64, ) -> bool { let seven_days_ago = now - 7 * 24 * 60 * 60; max_flame_counter > 2 && counter < max_flame_counter && max_flame_counter_from.is_some_and(|from| from > seven_days_ago) } #[cfg(test)] mod tests { use super::*; use crate::database::app::AppDatabase; use chrono::{Local, TimeZone}; const DAY: i64 = 24 * 60 * 60; /// Local midnight of the given date, which is what the derivation compares /// against. Mirrors the `DateTime(2026, 2, 2)` literals the Dart tests used. fn local_midnight(year: i32, month: u32, day: u32) -> i64 { Local .with_ymd_and_hms(year, month, day, 0, 0, 0) .earliest() .unwrap() .timestamp() } fn row(counter: i64, sent: i64, received: i64, changed: i64) -> FlameRow { FlameRow { group_id: "group".into(), flame_counter: counter, also_best_friend: 0, last_message_send: Some(sent), last_message_received: Some(received), last_flame_counter_change: Some(changed), } } #[test] fn a_streak_without_any_exchange_is_zero() { let empty = FlameRow { group_id: "group".into(), flame_counter: 7, also_best_friend: 0, last_message_send: None, last_message_received: None, last_flame_counter_change: None, }; assert_eq!(empty.derive(local_midnight(2026, 2, 3), false).counter, 0); } /// The day-by-day walk the Dart `normal flame expiring` test performed: one /// exchange on Feb 2 stays visible through Feb 4 and is gone on Feb 5. #[test] fn a_streak_survives_two_days_then_expires() { let sent = local_midnight(2026, 2, 2) + 15 * 3600; let received = local_midnight(2026, 2, 2) + 10 * 3600; let row = row(1, sent, received, sent); assert_eq!(row.derive(local_midnight(2026, 2, 2), false).counter, 1); assert_eq!(row.derive(local_midnight(2026, 2, 3), false).counter, 1); assert_eq!(row.derive(local_midnight(2026, 2, 4), false).counter, 1); assert_eq!(row.derive(local_midnight(2026, 2, 5), false).counter, 0); assert_eq!(row.derive(local_midnight(2026, 3, 1), false).counter, 0); } /// The `isExpiring` half of the same Dart test: an exchange from two days /// back still counts, but warns that it ends at midnight. #[test] fn a_streak_warns_on_the_day_before_it_lapses() { let exchange = local_midnight(2026, 2, 7) + 11 * 3600; let row = row(3, exchange, exchange - 3600, exchange); let same_day = row.derive(local_midnight(2026, 2, 7), false); assert_eq!((same_day.counter, same_day.is_expiring), (3, false)); let next_day = row.derive(local_midnight(2026, 2, 8), false); assert_eq!((next_day.counter, next_day.is_expiring), (3, false)); let last_day = row.derive(local_midnight(2026, 2, 9), false); assert_eq!((last_day.counter, last_day.is_expiring), (3, true)); let lapsed = row.derive(local_midnight(2026, 2, 10), false); assert_eq!((lapsed.counter, lapsed.is_expiring), (0, false)); } /// The Dart `isRestore Possible` test walked March 24 to 27 against a max /// set on March 20; the offer stops exactly seven days after that. #[test] fn restoring_is_offered_for_seven_days() { let max_from = local_midnight(2026, 3, 20) + 3 * 3600; for days in 4..=6 { assert!( can_restore(20, Some(max_from), 0, max_from + days * DAY), "expected the offer {days} days after the streak was lost" ); } assert!(!can_restore(20, Some(max_from), 0, max_from + 7 * DAY + 1)); } #[test] fn restoring_is_not_offered_without_a_streak_worth_restoring() { let now = local_midnight(2026, 3, 24); // Too short to miss. assert!(!can_restore(2, Some(now - DAY), 0, now)); // Nothing was lost. assert!(!can_restore(20, Some(now - DAY), 20, now)); // Never reached a maximum. assert!(!can_restore(20, None, 0, now)); } #[tokio::test] async fn restoring_brings_the_streak_back_to_its_maximum() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("app.sqlite"); let database = AppDatabase::new(path.to_str().unwrap(), None, false) .await .unwrap(); database.run_migrations().await.unwrap(); let lapsed = current_time().timestamp() - 4 * DAY; sqlx::query( r#"INSERT INTO groups(group_id, group_name, flame_counter, max_flame_counter, max_flame_counter_from, last_message_send, last_message_received, last_flame_counter_change) VALUES ('group', 'Group', 0, 5, ?, ?, ?, ?)"#, ) .bind(lapsed) .bind(lapsed) .bind(lapsed) .bind(lapsed) .execute(&database.pool) .await .unwrap(); assert_eq!( Group::flame_state(&database.pool, "group") .await .unwrap() .counter, 0 ); assert!(Group::can_restore_flames(&database.pool, "group") .await .unwrap()); let mut transaction = database.pool.begin().await.unwrap(); let restored = Group::restore_flames(&mut transaction, "group") .await .unwrap(); transaction.commit().await.unwrap(); assert_eq!(restored, Some(5)); let state = Group::flame_state(&database.pool, "group").await.unwrap(); assert_eq!((state.counter, state.is_expiring), (5, false)); // Nothing is left to restore once it is back. assert!(!Group::can_restore_flames(&database.pool, "group") .await .unwrap()); // The write path enforces the same rule, so a rapid second tap cannot // create another restoration message after the first one succeeded. let mut transaction = database.pool.begin().await.unwrap(); let restored_again = Group::restore_flames(&mut transaction, "group") .await .unwrap(); transaction.commit().await.unwrap(); assert_eq!(restored_again, None); } /// The heart emoji needs both halves: our most-exchanged chat, and their /// `also_best_friend` flag saying they picked us too. #[tokio::test] async fn the_best_friend_is_the_most_exchanged_chat_and_has_to_be_mutual() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("app.sqlite"); let database = AppDatabase::new(path.to_str().unwrap(), None, false) .await .unwrap(); database.run_migrations().await.unwrap(); let now = current_time().timestamp(); for (group_id, media, mutual) in [("top", 50, 1), ("second", 10, 1), ("onesided", 5, 0)] { sqlx::query( r#"INSERT INTO groups(group_id, group_name, total_media_counter, also_best_friend, flame_counter, last_message_send, last_message_received, last_flame_counter_change) VALUES (?, 'Group', ?, ?, 9, ?, ?, ?)"#, ) .bind(group_id) .bind(media) .bind(mutual) .bind(now) .bind(now) .bind(now) .execute(&database.pool) .await .unwrap(); } assert_eq!( Group::best_friend_group_id(&database.pool).await.unwrap(), Some("top".to_string()) ); let states = Group::flame_states(&database.pool, None).await.unwrap(); assert!(states["top"].is_best_friend); assert!(!states["second"].is_best_friend); assert!(!states["onesided"].is_best_friend); // A requested subset still resolves the best friend across all groups. let subset = Group::flame_states(&database.pool, Some(vec!["top".into()])) .await .unwrap(); assert_eq!(subset.len(), 1); assert!(subset["top"].is_best_friend); } #[tokio::test] async fn media_exchange_updates_contact_and_flame_counters_atomically() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("app.sqlite"); let database = AppDatabase::new(path.to_str().unwrap(), None, false) .await .unwrap(); database.run_migrations().await.unwrap(); sqlx::query("INSERT INTO contacts(user_id, username) VALUES (7, 'alice')") .execute(&database.pool) .await .unwrap(); sqlx::query("INSERT INTO groups(group_id, group_name) VALUES ('group', 'Group')") .execute(&database.pool) .await .unwrap(); sqlx::query("INSERT INTO group_members(group_id, contact_id) VALUES ('group', 7)") .execute(&database.pool) .await .unwrap(); let now = Local::now(); let start_of_today = Local .from_local_datetime(&now.date_naive().and_hms_opt(0, 0, 0).unwrap()) .earliest() .unwrap() .timestamp(); let mut transaction = database.pool.begin().await.unwrap(); Group::record_media_exchange(&mut transaction, "group", true, start_of_today + 60) .await .unwrap(); Group::record_media_exchange(&mut transaction, "group", false, start_of_today + 120) .await .unwrap(); transaction.commit().await.unwrap(); let contact = sqlx::query!( "SELECT media_send_counter, media_received_counter FROM contacts WHERE user_id = 7" ) .fetch_one(&database.pool) .await .unwrap(); assert_eq!(contact.media_send_counter, 1); assert_eq!(contact.media_received_counter, 1); let group = sqlx::query!( r#"SELECT total_media_counter, flame_counter, max_flame_counter, last_message_send, last_message_received, last_flame_counter_change FROM groups WHERE group_id = 'group'"# ) .fetch_one(&database.pool) .await .unwrap(); assert_eq!(group.total_media_counter, 2); assert_eq!(group.flame_counter, 1); assert_eq!(group.max_flame_counter, 1); assert_eq!(group.last_message_send, Some(start_of_today + 120)); assert_eq!(group.last_message_received, Some(start_of_today + 60)); assert_eq!(group.last_flame_counter_change, Some(start_of_today + 120)); } }