diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 56ba518049..e3e9e623a7 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -614,6 +614,15 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + /// Postgres `timestamptz` stores microsecond precision, so a raw + /// `Utc::now()` (nanoseconds) will not round-trip equal. Truncate to + /// microseconds up front so equality assertions compare like for like + /// while the seconds-apart fence arithmetic stays intact. + fn now_micros() -> DateTime { + use chrono::SubsecRound; + Utc::now().trunc_subsecs(6) + } + async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) @@ -690,7 +699,7 @@ mod tests { .expect("create dm"); // Recipient hid the DM before the message was received. - let hidden_at = Utc::now(); + let hidden_at = now_micros(); set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await; let message_received_at = hidden_at + chrono::Duration::seconds(1); @@ -725,7 +734,7 @@ mod tests { // Recipient re-hid the DM on another device AFTER the message arrived // (e.g. a delayed replay of that message races the newer user action). - let message_received_at = Utc::now(); + let message_received_at = now_micros(); let hidden_at = message_received_at + chrono::Duration::seconds(1); set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await; @@ -757,7 +766,7 @@ mod tests { .expect("create dm"); // Both participants have an old hide; only the recipient may be cleared. - let hidden_at = Utc::now(); + let hidden_at = now_micros(); set_hidden_at(&pool, community_id, dm.id, &sender, hidden_at).await; set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await; let message_received_at = hidden_at + chrono::Duration::seconds(1); diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index 1766828be2..8223b9a06a 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -165,10 +165,27 @@ async fn create_dm(requester_keys: &Keys, other_pubkey_hex: &str) -> String { /// Submit a signed command event via REST and assert it was accepted. async fn post_signed_event(keys: &Keys, kind: u16, tags: Vec) { + post_signed_event_created_at(keys, kind, tags, nostr::Timestamp::now()).await; +} + +/// Like [`post_signed_event`] but with an explicit `created_at`. A nostr event +/// id is `sha256(pubkey, created_at, kind, tags, content)` at second +/// resolution, so two otherwise-identical command events posted in the same +/// wall-clock second collide on id and the relay dedupes the second one +/// (`command_executor` returns `PersistResult::Duplicate` before re-running the +/// mutation). Callers that must post the same command twice as distinct actions +/// backdate the first to force distinct ids — the same guard `create_dm` uses. +async fn post_signed_event_created_at( + keys: &Keys, + kind: u16, + tags: Vec, + created_at: nostr::Timestamp, +) { let client = reqwest::Client::new(); let pubkey_hex = keys.public_key().to_hex(); let event = EventBuilder::new(Kind::Custom(kind), "") .tags(tags) + .custom_created_at(created_at) .sign_with_keys(keys) .unwrap(); let resp = client @@ -1456,10 +1473,14 @@ async fn test_nipdv_message_replay_preserves_a_newer_rehide() { let channel_id = create_dm(&keys_a, &b_pubkey_hex).await; // B hides the DM, then A sends a message. The message resurfaces it for B. - post_signed_event( + // Backdate this first hide so it cannot collide on event id with the + // identical re-hide posted later in the same wall-clock second (which the + // relay would dedupe, silently dropping the second hide). + post_signed_event_created_at( &keys_b, 41012, vec![Tag::parse(["h", &channel_id]).unwrap()], + nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 10), ) .await;