9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
539 lines
18 KiB
Rust
539 lines
18 KiB
Rust
//! End-to-end tests for NIP-23 long-form content (kind:30023).
|
|
//!
|
|
//! These tests require a running relay instance. By default they are marked
|
|
//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not
|
|
//! available.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! Start the relay, then run:
|
|
//!
|
|
//! ```text
|
|
//! cargo test --test e2e_long_form -- --ignored
|
|
//! ```
|
|
//!
|
|
//! Override the relay URL with the `RELAY_URL` environment variable:
|
|
//!
|
|
//! ```text
|
|
//! RELAY_URL=ws://relay.example.com cargo test --test e2e_long_form -- --ignored
|
|
//! ```
|
|
|
|
use std::time::Duration;
|
|
|
|
use buzz_test_client::BuzzTestClient;
|
|
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
|
|
|
const KIND_LONG_FORM: u16 = 30023;
|
|
|
|
fn relay_url() -> String {
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
|
}
|
|
|
|
fn sub_id(name: &str) -> String {
|
|
format!("e2e-{name}-{}", uuid::Uuid::new_v4())
|
|
}
|
|
|
|
/// Build a kind:30023 event with standard NIP-23 tags.
|
|
fn build_long_form_event(
|
|
keys: &Keys,
|
|
d_tag: &str,
|
|
title: &str,
|
|
content: &str,
|
|
extra_tags: Vec<Tag>,
|
|
) -> nostr::Event {
|
|
let mut tags = vec![
|
|
Tag::parse(["d", d_tag]).unwrap(),
|
|
Tag::parse(["title", title]).unwrap(),
|
|
];
|
|
tags.extend(extra_tags);
|
|
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), content)
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.unwrap()
|
|
}
|
|
|
|
/// kind:30023 events are accepted by the relay.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_accepted() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let event = build_long_form_event(
|
|
&keys,
|
|
"test-article-accept",
|
|
"Test Article",
|
|
"# Hello\n\nThis is a test article.",
|
|
vec![],
|
|
);
|
|
|
|
let ok = client.send_event(event).await.expect("send event");
|
|
assert!(
|
|
ok.accepted,
|
|
"relay should accept kind:30023: {}",
|
|
ok.message
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// kind:30023 events are retrievable via REQ with kinds filter.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_retrievable() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let d_tag = format!("retrieve-{}", uuid::Uuid::new_v4().simple());
|
|
let event = build_long_form_event(
|
|
&keys,
|
|
&d_tag,
|
|
"Retrievable Article",
|
|
"# Retrievable\n\nBody text.",
|
|
vec![],
|
|
);
|
|
let event_id = event.id;
|
|
|
|
let ok = client.send_event(event).await.expect("send event");
|
|
assert!(ok.accepted, "relay should accept: {}", ok.message);
|
|
|
|
// Query back by kind + author
|
|
let sid = sub_id("retrieve");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key());
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
|
|
let events = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
|
|
assert!(
|
|
events.iter().any(|e| e.id == event_id),
|
|
"should find the published article in query results"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// kind:30023 is stored globally (channel_id = NULL) — stray h-tags are ignored.
|
|
/// An event with a stray h-tag should still be retrievable via a global query
|
|
/// (no h-tag filter), proving it was stored as global.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_stray_h_tag_ignored() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
// Publish with a stray h-tag (a UUID that doesn't correspond to any channel).
|
|
let fake_channel = uuid::Uuid::new_v4().to_string();
|
|
let d_tag = format!("stray-h-{}", uuid::Uuid::new_v4().simple());
|
|
let event = build_long_form_event(
|
|
&keys,
|
|
&d_tag,
|
|
"Stray H-Tag Article",
|
|
"Should be stored globally despite h-tag.",
|
|
vec![Tag::parse(["h", &fake_channel]).unwrap()],
|
|
);
|
|
let event_id = event.id;
|
|
|
|
let ok = client.send_event(event).await.expect("send event");
|
|
assert!(ok.accepted, "relay should accept: {}", ok.message);
|
|
|
|
// Query globally (no h-tag filter) — should find the article.
|
|
let sid = sub_id("stray-h");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key());
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
|
|
let events = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
|
|
assert!(
|
|
events.iter().any(|e| e.id == event_id),
|
|
"article with stray h-tag should be retrievable via global query"
|
|
);
|
|
|
|
// NOTE: Ideally, querying with #h=<fake_channel> should NOT return the
|
|
// article since it's global. However, the raw h-tag remains on the stored
|
|
// event (Nostr events are signed — tags can't be stripped without breaking
|
|
// the signature), and the read-path filter matching in filter.rs treats
|
|
// explicit h-tags as authoritative. This is a pre-existing limitation
|
|
// affecting all global-only kinds (0, 1, 3, 30023) and should be fixed
|
|
// in the filter layer as a follow-up.
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// NIP-33 replacement: publishing a newer kind:30023 with the same d-tag replaces the old one.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_nip33_replacement() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple());
|
|
|
|
// Publish v1
|
|
let v1 = build_long_form_event(&keys, &d_tag, "Article v1", "Version 1 content.", vec![]);
|
|
let ok1 = client.send_event(v1).await.expect("send v1");
|
|
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
|
|
|
|
// Small delay to ensure different created_at timestamps
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
// Publish v2 with the same d-tag
|
|
let v2 = build_long_form_event(
|
|
&keys,
|
|
&d_tag,
|
|
"Article v2",
|
|
"Version 2 content — updated.",
|
|
vec![],
|
|
);
|
|
let v2_id = v2.id;
|
|
let ok2 = client.send_event(v2).await.expect("send v2");
|
|
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
|
|
|
|
// Query — should only get v2 (v1 replaced)
|
|
let sid = sub_id("replace");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
|
|
let events = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
|
|
assert_eq!(
|
|
events.len(),
|
|
1,
|
|
"should have exactly one event after replacement"
|
|
);
|
|
assert_eq!(events[0].id, v2_id, "surviving event should be v2");
|
|
assert!(
|
|
events[0].content.contains("Version 2"),
|
|
"content should be v2"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// NIP-33 stale-write protection: an older event cannot replace a newer one.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_stale_write_rejected() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let d_tag = format!("stale-{}", uuid::Uuid::new_v4().simple());
|
|
|
|
// Publish the "newer" event first (with a future-ish timestamp)
|
|
let newer = {
|
|
let tags = vec![
|
|
Tag::parse(["d", &d_tag]).unwrap(),
|
|
Tag::parse(["title", "Newer Article"]).unwrap(),
|
|
];
|
|
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Newer content.")
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() + 100))
|
|
.sign_with_keys(&keys)
|
|
.unwrap()
|
|
};
|
|
let newer_id = newer.id;
|
|
let ok1 = client.send_event(newer).await.expect("send newer");
|
|
assert!(ok1.accepted, "newer should be accepted: {}", ok1.message);
|
|
|
|
// Now try to publish an "older" event with the same d-tag but earlier timestamp
|
|
let older = {
|
|
let tags = vec![
|
|
Tag::parse(["d", &d_tag]).unwrap(),
|
|
Tag::parse(["title", "Older Article"]).unwrap(),
|
|
];
|
|
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Older content.")
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() - 100))
|
|
.sign_with_keys(&keys)
|
|
.unwrap()
|
|
};
|
|
let _ok2 = client.send_event(older).await.expect("send older");
|
|
// Stale write may be rejected or accepted-as-duplicate — either way,
|
|
// the older event must NOT replace the newer one.
|
|
|
|
// Query — should still have the newer event
|
|
let sid = sub_id("stale");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
|
|
let events = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
|
|
assert_eq!(events.len(), 1, "should have exactly one event");
|
|
assert_eq!(
|
|
events[0].id, newer_id,
|
|
"surviving event should be the newer one"
|
|
);
|
|
assert!(
|
|
events[0].content.contains("Newer"),
|
|
"content should be from the newer event"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// NIP-09 a-tag deletion: a kind:5 deletion targeting the addressable
|
|
/// coordinate `30023:<pubkey>:<d-tag>` causes the live event row for that
|
|
/// coordinate to be soft-deleted, so subsequent REQs no longer return it.
|
|
///
|
|
/// Regression test for issue #714 — before the fix,
|
|
/// `handle_a_tag_deletion` only handled the workflow kind and silently
|
|
/// no-op'd for kind:30023.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_a_tag_deletion() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
// Publish a note.
|
|
let d_tag = format!("a-del-{}", uuid::Uuid::new_v4().simple());
|
|
let note = build_long_form_event(&keys, &d_tag, "Doomed Article", "Body.", vec![]);
|
|
let note_id = note.id;
|
|
let ok = client.send_event(note).await.expect("send note");
|
|
assert!(ok.accepted, "note should be accepted: {}", ok.message);
|
|
|
|
// Sanity check it's queryable before deletion.
|
|
let sid_pre = sub_id("a-del-pre");
|
|
let filter_pre = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
|
client
|
|
.subscribe(&sid_pre, vec![filter_pre])
|
|
.await
|
|
.expect("subscribe pre");
|
|
let pre = client
|
|
.collect_until_eose(&sid_pre, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect pre");
|
|
assert!(
|
|
pre.iter().any(|e| e.id == note_id),
|
|
"note should be queryable before deletion"
|
|
);
|
|
|
|
// Build the addressable coordinate and emit a kind:5 deletion targeting it.
|
|
let a_coord = format!(
|
|
"{}:{}:{}",
|
|
KIND_LONG_FORM,
|
|
keys.public_key().to_hex(),
|
|
d_tag
|
|
);
|
|
let del = EventBuilder::new(Kind::EventDeletion, "")
|
|
.tags(vec![Tag::parse(["a", &a_coord]).unwrap()])
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
let ok_del = client.send_event(del).await.expect("send deletion");
|
|
assert!(
|
|
ok_del.accepted,
|
|
"a-tag deletion should be accepted: {}",
|
|
ok_del.message
|
|
);
|
|
|
|
// Query — should now be empty.
|
|
let sid_post = sub_id("a-del-post");
|
|
let filter_post = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
|
client
|
|
.subscribe(&sid_post, vec![filter_post])
|
|
.await
|
|
.expect("subscribe post");
|
|
let post = client
|
|
.collect_until_eose(&sid_post, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect post");
|
|
assert!(
|
|
post.is_empty(),
|
|
"a-tag deletion should remove the note from REQ results (got {} events)",
|
|
post.len()
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// A kind:5 carrying a malformed `e` tag alongside a valid `a` coordinate must
|
|
/// NOT be routed as an addressable deletion — a malformed `e` makes the
|
|
/// deletion ambiguous, not addressable-only. Regression guard for relay
|
|
/// routing keyed on "no e tags present" rather than "no valid e-ids decoded":
|
|
/// the note must survive.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_malformed_e_plus_a_does_not_delete() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let d_tag = format!("mixed-del-{}", uuid::Uuid::new_v4().simple());
|
|
let note = build_long_form_event(&keys, &d_tag, "Survivor", "Body.", vec![]);
|
|
let note_id = note.id;
|
|
let ok = client.send_event(note).await.expect("send note");
|
|
assert!(ok.accepted, "note should be accepted: {}", ok.message);
|
|
|
|
// kind:5 with a *malformed* e tag (not 64 hex chars) plus a valid a coord.
|
|
let a_coord = format!(
|
|
"{}:{}:{}",
|
|
KIND_LONG_FORM,
|
|
keys.public_key().to_hex(),
|
|
d_tag
|
|
);
|
|
let del = EventBuilder::new(Kind::EventDeletion, "")
|
|
.tags(vec![
|
|
Tag::parse(["e", "not-a-valid-event-id"]).unwrap(),
|
|
Tag::parse(["a", &a_coord]).unwrap(),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
// Relay may accept-and-noop or reject; either is fine. The contract under
|
|
// test is that the coordinate is NOT soft-deleted.
|
|
let _ = client.send_event(del).await.expect("send mixed deletion");
|
|
|
|
let sid = sub_id("mixed-del-post");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
let post = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
assert!(
|
|
post.iter().any(|e| e.id == note_id),
|
|
"malformed-e + a must NOT soft-delete the coordinate; note should survive"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// `notes set` re-publish preserves the original `published_at` while letting
|
|
/// `created_at` advance. This is the contract that NIP-23 readers rely on to
|
|
/// tell "when the author first wrote this" from "when they last updated it",
|
|
/// and the carry-forward logic in `buzz-cli`'s `build_set_event` (unit-tested
|
|
/// there) only works if the relay round-trips the tag faithfully.
|
|
///
|
|
/// The carry rule is duplicated inline here (rather than reaching into
|
|
/// `buzz-cli`) so this e2e crate stays free of CLI deps; the rule's
|
|
/// correctness is unit-tested in `commands::notes::tests`.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_long_form_set_twice_preserves_published_at() {
|
|
let url = relay_url();
|
|
let keys = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
|
|
|
let d_tag = format!("preserve-pat-{}", uuid::Uuid::new_v4().simple());
|
|
let original_published_at: u64 = 1_700_000_000;
|
|
|
|
// First publish: stamp `published_at` = original_published_at.
|
|
let v1 = build_long_form_event(
|
|
&keys,
|
|
&d_tag,
|
|
"First",
|
|
"v1 body",
|
|
vec![Tag::parse(["published_at", &original_published_at.to_string()]).unwrap()],
|
|
);
|
|
let ok1 = client.send_event(v1).await.expect("send v1");
|
|
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
|
|
|
|
// Ensure created_at advances between writes.
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
// Re-publish carrying the original `published_at` forward — what
|
|
// `notes set` does on update when `--title` (or nothing) changes.
|
|
let v2 = EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "v2 body")
|
|
.tags(vec![
|
|
Tag::parse(["d", &d_tag]).unwrap(),
|
|
Tag::parse(["title", "First"]).unwrap(),
|
|
Tag::parse(["published_at", &original_published_at.to_string()]).unwrap(),
|
|
])
|
|
.custom_created_at(Timestamp::now())
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
let v2_id = v2.id;
|
|
let v2_created_at = v2.created_at.as_secs();
|
|
let ok2 = client.send_event(v2).await.expect("send v2");
|
|
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
|
|
|
|
// Re-fetch: there should be exactly one live event for (kind, author, d-tag),
|
|
// and its `published_at` should still be the original — even though
|
|
// `created_at` advanced.
|
|
let sid = sub_id("preserve-pat");
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(KIND_LONG_FORM))
|
|
.author(keys.public_key())
|
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
|
|
let events = client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect");
|
|
|
|
assert_eq!(events.len(), 1, "exactly one live event after re-publish");
|
|
let live = &events[0];
|
|
assert_eq!(live.id, v2_id, "surviving event is v2");
|
|
assert_eq!(
|
|
live.created_at.as_secs(),
|
|
v2_created_at,
|
|
"created_at advanced to v2's timestamp"
|
|
);
|
|
let pa = live
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.as_slice().first().map(String::as_str) == Some("published_at"))
|
|
.and_then(|t| t.as_slice().get(1).cloned())
|
|
.and_then(|v| v.parse::<u64>().ok());
|
|
assert_eq!(
|
|
pa,
|
|
Some(original_published_at),
|
|
"published_at must be preserved across re-publish"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|