use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt;
use std::sync::Mutex;
use std::time::Instant;
use mdk_storage_traits::{GroupId, MdkStorageError, MdkStorageProvider};
use nostr::EventId;
use crate::Error;
#[derive(Clone)]
pub struct EpochSnapshot {
pub group_id: GroupId,
pub epoch: u64,
pub applied_commit_id: EventId,
pub applied_commit_ts: u64,
pub applied_commit_content_hash: [u8; 32],
pub created_at: Instant,
pub snapshot_name: String,
}
impl fmt::Debug for EpochSnapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"EpochSnapshot {{ group_id: [REDACTED], epoch: {}, applied_commit_id: {:?}, applied_commit_ts: {}, snapshot_name: [REDACTED] }}",
self.epoch, self.applied_commit_id, self.applied_commit_ts,
)
}
}
struct EpochSnapshotManagerInner {
snapshots: HashMap<GroupId, VecDeque<EpochSnapshot>>,
hydrated_groups: HashSet<GroupId>,
}
impl fmt::Debug for EpochSnapshotManagerInner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total_snapshots: usize = self.snapshots.values().map(|q| q.len()).sum();
write!(
f,
"EpochSnapshotManagerInner {{ groups: {}, total_snapshots: {} }}",
self.snapshots.len(),
total_snapshots
)
}
}
pub struct EpochSnapshotManager {
inner: Mutex<EpochSnapshotManagerInner>,
retention_count: usize,
}
impl fmt::Debug for EpochSnapshotManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EpochSnapshotManager")
.field("inner", &"[REDACTED]")
.field("retention_count", &self.retention_count)
.finish()
}
}
impl EpochSnapshotManager {
pub fn new(retention_count: usize) -> Self {
Self {
inner: Mutex::new(EpochSnapshotManagerInner {
snapshots: HashMap::new(),
hydrated_groups: HashSet::new(),
}),
retention_count,
}
}
pub fn remove_group(&self, group_id: &GroupId) {
let mut inner = self.inner.lock().unwrap();
inner.snapshots.remove(group_id);
inner.hydrated_groups.remove(group_id);
}
fn ensure_hydrated<S: MdkStorageProvider>(&self, storage: &S, group_id: &GroupId) {
if !storage.backend().is_persistent() {
return;
}
let mut inner = self.inner.lock().unwrap();
if inner.hydrated_groups.contains(group_id) {
return;
}
let stored_snapshots = match storage.list_group_snapshots(group_id) {
Ok(snapshots) => snapshots,
Err(e) => {
tracing::warn!("Failed to load snapshots for hydration: {}", e);
inner.hydrated_groups.insert(group_id.clone());
return;
}
};
let queue = inner.snapshots.entry(group_id.clone()).or_default();
for (snapshot_name, created_at_unix) in stored_snapshots {
if let Some(snapshot) =
Self::parse_snapshot_name(&snapshot_name, group_id, created_at_unix)
{
queue.push_back(snapshot);
} else {
tracing::warn!(
"Failed to parse snapshot name during hydration: {}",
snapshot_name
);
}
}
while queue.len() > self.retention_count {
if let Some(old_snap) = queue.pop_front() {
let _ = storage.release_group_snapshot(&old_snap.group_id, &old_snap.snapshot_name);
}
}
inner.hydrated_groups.insert(group_id.clone());
}
fn parse_snapshot_name(
snapshot_name: &str,
group_id: &GroupId,
_created_at_unix: u64,
) -> Option<EpochSnapshot> {
let parts: Vec<&str> = snapshot_name.split('_').collect();
if parts.len() != 4 || parts[0] != "snap" {
return None;
}
let epoch: u64 = parts[2].parse().ok()?;
let commit_id = EventId::parse(parts[3]).ok()?;
Some(EpochSnapshot {
group_id: group_id.clone(),
epoch,
applied_commit_id: commit_id,
applied_commit_ts: 0, applied_commit_content_hash: [0u8; 32], created_at: Instant::now(), snapshot_name: snapshot_name.to_string(),
})
}
pub fn create_snapshot<S: MdkStorageProvider>(
&self,
storage: &S,
group_id: &GroupId,
current_epoch: u64,
commit_id: &EventId,
commit_ts: u64,
content_hash: &[u8; 32],
) -> Result<String, Error> {
self.ensure_hydrated(storage, group_id);
let snapshot_name = format!(
"snap_{}_{}_{}",
hex::encode(group_id.as_slice()),
current_epoch,
commit_id.to_hex()
);
storage
.create_group_snapshot(group_id, &snapshot_name)
.map_err(Error::Storage)?;
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: current_epoch,
applied_commit_id: *commit_id,
applied_commit_ts: commit_ts,
applied_commit_content_hash: *content_hash,
created_at: Instant::now(),
snapshot_name: snapshot_name.clone(),
};
let mut inner = self.inner.lock().unwrap();
let queue = inner.snapshots.entry(group_id.clone()).or_default();
queue.push_back(snapshot);
while queue.len() > self.retention_count {
if let Some(old_snap) = queue.pop_front() {
let _ = storage.release_group_snapshot(&old_snap.group_id, &old_snap.snapshot_name);
}
}
Ok(snapshot_name)
}
pub fn is_better_candidate<S: MdkStorageProvider>(
&self,
storage: &S,
group_id: &GroupId,
candidate_epoch: u64,
candidate_ts: u64,
candidate_id: &EventId,
candidate_content_hash: &[u8; 32],
) -> bool {
self.ensure_hydrated(storage, group_id);
let mut inner = self.inner.lock().unwrap();
if let Some(queue) = inner.snapshots.get_mut(group_id)
&& let Some(snapshot) = queue.iter_mut().find(|s| s.epoch == candidate_epoch)
{
if snapshot.applied_commit_ts == 0 {
return false;
}
if snapshot.applied_commit_content_hash != [0u8; 32]
&& candidate_content_hash == &snapshot.applied_commit_content_hash
{
let candidate_is_better_wrapper = candidate_ts < snapshot.applied_commit_ts
|| (candidate_ts == snapshot.applied_commit_ts
&& candidate_id.to_hex() < snapshot.applied_commit_id.to_hex());
if candidate_is_better_wrapper {
tracing::debug!(
target: "mdk_core::epoch_snapshots",
"Updating canonical ordering key for epoch {} (same ciphertext, better wrapper metadata)",
candidate_epoch,
);
snapshot.applied_commit_ts = candidate_ts;
snapshot.applied_commit_id = *candidate_id;
}
return false;
}
if candidate_ts < snapshot.applied_commit_ts {
return true;
}
if candidate_ts > snapshot.applied_commit_ts {
return false;
}
if candidate_id.to_hex() < snapshot.applied_commit_id.to_hex() {
return true;
}
}
false
}
pub fn rollback_to_epoch<S: MdkStorageProvider>(
&self,
storage: &S,
group_id: &GroupId,
target_epoch: u64,
) -> Result<(), Error> {
self.ensure_hydrated(storage, group_id);
let mut inner = self.inner.lock().unwrap();
if let Some(queue) = inner.snapshots.get_mut(group_id) {
if let Some(index) = queue.iter().position(|s| s.epoch == target_epoch) {
let snapshot = &queue[index];
storage
.rollback_group_to_snapshot(group_id, &snapshot.snapshot_name)
.map_err(Error::Storage)?;
let removed = queue.split_off(index);
for (i, snap) in removed.into_iter().enumerate() {
if i > 0 {
let _ = storage.release_group_snapshot(&snap.group_id, &snap.snapshot_name);
}
}
return Ok(());
}
}
Err(Error::Storage(MdkStorageError::NotFound(
"No snapshot found for target epoch".to_string(),
)))
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::str::FromStr;
use mdk_storage_traits::groups::GroupStorage;
use mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState};
use nostr::PublicKey;
use super::*;
fn test_group_id(id: u8) -> GroupId {
GroupId::from_slice(&[id; 32])
}
fn test_event_id(hex: &str) -> EventId {
EventId::from_str(hex).unwrap()
}
#[test]
fn test_new_creates_manager_with_retention_count() {
let manager = EpochSnapshotManager::new(5);
assert_eq!(manager.retention_count, 5);
}
#[test]
fn test_new_with_zero_retention() {
let manager = EpochSnapshotManager::new(0);
assert_eq!(manager.retention_count, 0);
}
#[test]
fn test_is_better_candidate_earlier_timestamp_wins() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000, applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
assert!(manager.is_better_candidate(
&storage,
&group_id,
10,
999,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_later_timestamp_loses() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("0000000000000000000000000000000000000000000000000000000000000000");
assert!(!manager.is_better_candidate(
&storage,
&group_id,
10,
1001,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_smaller_id_wins_on_same_timestamp() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
assert!(manager.is_better_candidate(
&storage,
&group_id,
10,
1000,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_larger_id_loses_on_same_timestamp() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
assert!(!manager.is_better_candidate(
&storage,
&group_id,
10,
1000,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_same_id_returns_false() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
assert!(!manager.is_better_candidate(
&storage,
&group_id,
10,
1000,
&applied_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_wrong_epoch_returns_false() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("0000000000000000000000000000000000000000000000000000000000000000");
assert!(!manager.is_better_candidate(
&storage,
&group_id,
11,
999,
&candidate_id,
&[2u8; 32]
)); }
#[test]
fn test_is_better_candidate_unknown_group_returns_false() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let unknown_group_id = test_group_id(99);
let candidate_id =
test_event_id("0000000000000000000000000000000000000000000000000000000000000000");
assert!(!manager.is_better_candidate(
&storage,
&unknown_group_id,
10,
999,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_is_better_candidate_same_content_hash_returns_false() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let applied_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
let content_hash = [0xABu8; 32];
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group_id.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: content_hash,
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
assert!(
!manager.is_better_candidate(
&storage,
&group_id,
10,
999,
&candidate_id,
&content_hash
),
"Same content hash should be rejected even with earlier timestamp and smaller ID"
);
{
let inner = manager.inner.lock().unwrap();
let snapshot = inner.snapshots.get(&group_id).unwrap().front().unwrap();
assert_eq!(
snapshot.applied_commit_ts, 999,
"Snapshot ts should be updated to the earlier wrapper"
);
assert_eq!(
snapshot.applied_commit_id, candidate_id,
"Snapshot id should be updated to the smaller wrapper id"
);
}
let worse_id =
test_event_id("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
assert!(!manager.is_better_candidate(
&storage,
&group_id,
10,
2000,
&worse_id,
&content_hash
));
{
let inner = manager.inner.lock().unwrap();
let snapshot = inner.snapshots.get(&group_id).unwrap().front().unwrap();
assert_eq!(
snapshot.applied_commit_ts, 999,
"Snapshot ts should remain at the canonical minimum"
);
}
let different_hash = [0xCDu8; 32];
assert!(
manager.is_better_candidate(
&storage,
&group_id,
10,
998,
&candidate_id,
&different_hash
),
"Different content hash with earlier timestamp should be accepted"
);
}
#[test]
fn test_rollback_to_nonexistent_epoch_fails() {
let manager = EpochSnapshotManager::new(5);
let group_id = test_group_id(1);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let result = manager.rollback_to_epoch(&storage, &group_id, 10);
assert!(result.is_err());
match result.unwrap_err() {
Error::Storage(MdkStorageError::NotFound(msg)) => {
assert!(msg.contains("No snapshot found"));
}
_ => panic!("Expected NotFound error"),
}
}
#[test]
fn test_rollback_to_unknown_group_fails() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let unknown_group_id = test_group_id(99);
let result = manager.rollback_to_epoch(&storage, &unknown_group_id, 10);
assert!(result.is_err());
}
#[test]
fn test_snapshots_isolated_per_group() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group1 = test_group_id(1);
let group2 = test_group_id(2);
let applied_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
{
let mut inner = manager.inner.lock().unwrap();
let snapshot = EpochSnapshot {
group_id: group1.clone(),
epoch: 10,
applied_commit_id: applied_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "test_snap".to_string(),
};
inner
.snapshots
.entry(group1.clone())
.or_default()
.push_back(snapshot);
}
let candidate_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
assert!(manager.is_better_candidate(&storage, &group1, 10, 999, &candidate_id, &[2u8; 32]));
assert!(!manager.is_better_candidate(
&storage,
&group2,
10,
999,
&candidate_id,
&[2u8; 32]
));
}
#[test]
fn test_snapshot_retention_pruning() {
let manager = EpochSnapshotManager::new(2); let group_id = test_group_id(1);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let admin_pk = PublicKey::from_slice(&[2u8; 32]).unwrap();
let mut admin_pubkeys = BTreeSet::new();
admin_pubkeys.insert(admin_pk);
let group = Group {
mls_group_id: group_id.clone(),
nostr_group_id: [0u8; 32],
name: "Test Group".to_string(),
description: "Test".to_string(),
admin_pubkeys,
epoch: 0,
last_message_at: None,
last_message_processed_at: None,
last_message_id: None,
image_hash: None,
image_key: None,
image_nonce: None,
state: GroupState::Active,
self_update_state: SelfUpdateState::Required,
};
storage.save_group(group).unwrap();
for epoch in 0..3 {
let commit_id = test_event_id(&format!("{:064x}", epoch + 1));
let _ = manager.create_snapshot(
&storage,
&group_id,
epoch,
&commit_id,
1000 + epoch,
&[1u8; 32],
);
}
let inner = manager.inner.lock().unwrap();
let queue = inner.snapshots.get(&group_id).unwrap();
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].epoch, 1);
assert_eq!(queue[1].epoch, 2);
}
#[test]
fn test_rollback_removes_subsequent_snapshots() {
let manager = EpochSnapshotManager::new(10);
let group_id = test_group_id(1);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let admin_pk = PublicKey::from_slice(&[2u8; 32]).unwrap();
let mut admin_pubkeys = BTreeSet::new();
admin_pubkeys.insert(admin_pk);
let group = Group {
mls_group_id: group_id.clone(),
nostr_group_id: [0u8; 32],
name: "Test Group".to_string(),
description: "Test".to_string(),
admin_pubkeys,
epoch: 0,
last_message_at: None,
last_message_processed_at: None,
last_message_id: None,
image_hash: None,
image_key: None,
image_nonce: None,
state: GroupState::Active,
self_update_state: SelfUpdateState::Required,
};
storage.save_group(group).unwrap();
for epoch in 0..4 {
let commit_id = test_event_id(&format!("{:064x}", epoch + 1));
manager
.create_snapshot(
&storage,
&group_id,
epoch,
&commit_id,
1000 + epoch,
&[1u8; 32],
)
.unwrap();
}
{
let inner = manager.inner.lock().unwrap();
assert_eq!(inner.snapshots.get(&group_id).unwrap().len(), 4);
}
manager.rollback_to_epoch(&storage, &group_id, 1).unwrap();
{
let inner = manager.inner.lock().unwrap();
let queue = inner.snapshots.get(&group_id).unwrap();
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].epoch, 0);
}
}
#[test]
fn test_epoch_snapshot_debug_redacts_sensitive_data() {
let group_id = test_group_id(1);
let event_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
let snapshot = EpochSnapshot {
group_id,
epoch: 10,
applied_commit_id: event_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "snap_abc123".to_string(),
};
let debug_str = format!("{:?}", snapshot);
assert!(debug_str.contains("[REDACTED]"));
assert!(debug_str.contains("epoch: 10"));
assert!(!debug_str.contains("snap_abc123"));
}
#[test]
fn test_epoch_snapshot_manager_debug_redacts_inner() {
let manager = EpochSnapshotManager::new(5);
let debug_str = format!("{:?}", manager);
assert!(debug_str.contains("EpochSnapshotManager"));
assert!(debug_str.contains("[REDACTED]"));
assert!(debug_str.contains("retention_count: 5"));
}
#[test]
fn test_epoch_snapshot_manager_inner_debug_shows_counts() {
let manager = EpochSnapshotManager::new(5);
let group1 = test_group_id(1);
let group2 = test_group_id(2);
let event_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
{
let mut inner = manager.inner.lock().unwrap();
for epoch in 0..2 {
let snapshot = EpochSnapshot {
group_id: group1.clone(),
epoch,
applied_commit_id: event_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: format!("snap_{}", epoch),
};
inner
.snapshots
.entry(group1.clone())
.or_default()
.push_back(snapshot);
}
let snapshot = EpochSnapshot {
group_id: group2.clone(),
epoch: 0,
applied_commit_id: event_id,
applied_commit_ts: 1000,
applied_commit_content_hash: [1u8; 32],
created_at: Instant::now(),
snapshot_name: "snap_0".to_string(),
};
inner
.snapshots
.entry(group2.clone())
.or_default()
.push_back(snapshot);
let debug_str = format!("{:?}", *inner);
assert!(debug_str.contains("groups: 2"));
assert!(debug_str.contains("total_snapshots: 3"));
}
}
#[test]
fn test_create_snapshot_generates_unique_name() {
let manager = EpochSnapshotManager::new(5);
let group_id = test_group_id(1);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let admin_pk = PublicKey::from_slice(&[2u8; 32]).unwrap();
let mut admin_pubkeys = BTreeSet::new();
admin_pubkeys.insert(admin_pk);
let group = Group {
mls_group_id: group_id.clone(),
nostr_group_id: [0u8; 32],
name: "Test Group".to_string(),
description: "Test".to_string(),
admin_pubkeys,
epoch: 0,
last_message_at: None,
last_message_processed_at: None,
last_message_id: None,
image_hash: None,
image_key: None,
image_nonce: None,
state: GroupState::Active,
self_update_state: SelfUpdateState::Required,
};
storage.save_group(group).unwrap();
let commit_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
let name = manager
.create_snapshot(&storage, &group_id, 5, &commit_id, 1000, &[1u8; 32])
.unwrap();
assert!(name.starts_with("snap_"));
assert!(name.contains("_5_")); assert!(name.contains("aaaa")); }
#[test]
fn test_hydration_skipped_for_memory_storage() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let candidate_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
let result =
manager.is_better_candidate(&storage, &group_id, 5, 999, &candidate_id, &[2u8; 32]);
assert!(
!result,
"Should return false for non-persistent storage with no snapshots"
);
{
let inner = manager.inner.lock().unwrap();
assert!(
!inner.snapshots.contains_key(&group_id)
|| inner.snapshots.get(&group_id).unwrap().is_empty(),
"Memory storage should not trigger hydration"
);
}
}
#[test]
fn test_memory_storage_not_tracked_for_hydration() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(1);
let candidate_id =
test_event_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
let _ = manager.is_better_candidate(&storage, &group_id, 5, 999, &candidate_id, &[2u8; 32]);
{
let inner = manager.inner.lock().unwrap();
assert!(
!inner.hydrated_groups.contains(&group_id),
"Memory storage groups should not be tracked in hydrated_groups"
);
}
}
#[test]
fn test_snapshot_name_format() {
let manager = EpochSnapshotManager::new(5);
let storage = mdk_memory_storage::MdkMemoryStorage::default();
let group_id = test_group_id(42);
let admin_pk = PublicKey::from_slice(&[2u8; 32]).unwrap();
let mut admin_pubkeys = BTreeSet::new();
admin_pubkeys.insert(admin_pk);
let group = Group {
mls_group_id: group_id.clone(),
nostr_group_id: [0u8; 32],
name: "Test Group".to_string(),
description: "Test".to_string(),
admin_pubkeys,
epoch: 0,
last_message_at: None,
last_message_processed_at: None,
last_message_id: None,
image_hash: None,
image_key: None,
image_nonce: None,
state: GroupState::Active,
self_update_state: SelfUpdateState::Required,
};
storage.save_group(group).unwrap();
let commit_id =
test_event_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
let epoch = 7u64;
let name = manager
.create_snapshot(&storage, &group_id, epoch, &commit_id, 1000, &[1u8; 32])
.unwrap();
let parts: Vec<&str> = name.split('_').collect();
assert_eq!(parts.len(), 4, "Snapshot name should have 4 parts");
assert_eq!(parts[0], "snap", "First part should be 'snap'");
assert_eq!(parts[2], "7", "Third part should be the epoch");
assert_eq!(parts[1].len(), 64, "Group ID hex should be 64 chars");
assert_eq!(parts[3].len(), 64, "Commit ID hex should be 64 chars");
}
#[test]
fn test_remove_group_clears_state() {
let manager = EpochSnapshotManager::new(5);
let group_id = test_group_id(1);
{
let mut inner = manager.inner.lock().unwrap();
inner
.snapshots
.entry(group_id.clone())
.or_default()
.push_back(EpochSnapshot {
group_id: group_id.clone(),
epoch: 5,
applied_commit_id: test_event_id(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
),
applied_commit_ts: 999,
applied_commit_content_hash: [0u8; 32],
created_at: std::time::Instant::now(),
snapshot_name: "snap_test".to_string(),
});
inner.hydrated_groups.insert(group_id.clone());
}
{
let inner = manager.inner.lock().unwrap();
assert!(inner.snapshots.contains_key(&group_id));
assert!(inner.hydrated_groups.contains(&group_id));
}
manager.remove_group(&group_id);
{
let inner = manager.inner.lock().unwrap();
assert!(!inner.snapshots.contains_key(&group_id));
assert!(!inner.hydrated_groups.contains(&group_id));
}
}
#[test]
fn test_remove_group_is_idempotent() {
let manager = EpochSnapshotManager::new(5);
let group_id = test_group_id(42);
manager.remove_group(&group_id);
let inner = manager.inner.lock().unwrap();
assert!(!inner.snapshots.contains_key(&group_id));
}
}