mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: sprout notes NIP-23 long-form CLI + relay a-tag deletion (#719)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max (sprout agent) <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn (sprout agent) <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
Dawn
Max
Quinn
parent
441b6c4b1a
commit
504cfea20d
@@ -401,6 +401,44 @@ sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
|
||||
sprout messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
|
||||
```
|
||||
|
||||
### 6.12 Notes (NIP-23 long-form, kind:30023)
|
||||
|
||||
Editable team-knowledge notes keyed by `(kind:30023, you, d=slug)`. `set` is an
|
||||
idempotent upsert; `rm` is a NIP-09 a-tag deletion. Output is plain text (refs),
|
||||
not JSON — except `get`/`ls`, which emit JSON.
|
||||
|
||||
```bash
|
||||
# set (first publish — --title required, body from stdin)
|
||||
cat <<'EOF' | sprout notes set --name dco-check --title "DCO Check" \
|
||||
--summary "How we verify DCO" --tag dco --tag ci --content -
|
||||
Run `git log --format='%(trailers:key=Signed-off-by)'` ...
|
||||
EOF
|
||||
# → prints event_id / naddr / coordinate / slug / title
|
||||
|
||||
# set (edit — omit --title to carry it forward; published_at preserved)
|
||||
echo "Updated body." | sprout notes set --name dco-check --content -
|
||||
|
||||
# get by name (own author resolves directly; cross-author #d query otherwise)
|
||||
sprout notes get --name dco-check | jq .
|
||||
sprout notes get --name dco-check --content-only
|
||||
|
||||
# get by naddr (exact coordinate; paste the naddr from a set/get above)
|
||||
sprout notes get --naddr "$NADDR" | jq .
|
||||
|
||||
# ls (own by default; --author all across the team; --tag filters)
|
||||
sprout notes ls | jq .
|
||||
sprout notes ls --tag dco | jq .
|
||||
sprout notes ls --author all --limit 10 | jq .
|
||||
|
||||
# rm (NIP-09 a-tag deletion; subsequent get must 404)
|
||||
sprout notes rm --name dco-check
|
||||
# → prints deleted <coordinate> / deletion <event-id>
|
||||
sprout notes get --name dco-check # exits non-zero: not found
|
||||
|
||||
# rm of a slug you never published → NotFound, no kind:5 emitted
|
||||
sprout notes rm --name does-not-exist # exits non-zero
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Path Testing
|
||||
@@ -532,3 +570,7 @@ sprout channels delete --channel "$FORUM_ID" | jq .
|
||||
| 52 | `upload file` | ☐ | |
|
||||
| 53 | `pack validate` | ☐ | Local, no relay |
|
||||
| 54 | `pack inspect` | ☐ | Local, no relay |
|
||||
| 55 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard |
|
||||
| 56 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
|
||||
| 57 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
|
||||
| 58 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod dms;
|
||||
pub mod feed;
|
||||
pub mod mem;
|
||||
pub mod messages;
|
||||
pub mod notes;
|
||||
pub mod pack;
|
||||
pub mod reactions;
|
||||
pub mod repos;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,6 +178,9 @@ enum Cmd {
|
||||
/// Publish notes and manage the social graph (NIP-01/02)
|
||||
#[command(subcommand)]
|
||||
Social(SocialCmd),
|
||||
/// Publish and edit long-form NIP-23 notes — team knowledge base
|
||||
#[command(subcommand)]
|
||||
Notes(NotesCmd),
|
||||
/// Announce and discover git repositories (NIP-34)
|
||||
#[command(subcommand)]
|
||||
Repos(ReposCmd),
|
||||
@@ -774,6 +777,85 @@ pub enum SocialCmd {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes subcommands (NIP-23 long-form)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum NotesCmd {
|
||||
/// Create or update a note. Idempotent upsert keyed by `(me, --name)`.
|
||||
///
|
||||
/// `published_at` is preserved on edits (only set on first create).
|
||||
/// `--title` is required on first create; on subsequent edits the existing
|
||||
/// title is carried forward when `--title` is omitted, and `--title ""`
|
||||
/// explicitly clears it.
|
||||
#[command(
|
||||
after_help = "Examples:\n echo '# Hello' | sprout notes set --name hello --title 'Hello' --content -\n sprout notes set --name hello --tag onboarding --content - < draft.md"
|
||||
)]
|
||||
Set {
|
||||
/// Slug — becomes the `d` tag. `[a-z0-9._-]{1,80}`.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Note title (NIP-23 `title` tag). Required on first create; omit to carry; `""` to clear.
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
/// Short summary (NIP-23 `summary` tag). Omit to carry; `""` to clear.
|
||||
#[arg(long)]
|
||||
summary: Option<String>,
|
||||
/// Topic tag (NIP-23 `t` tag). May be repeated. Replaces (not merges) existing tags on edit; omit to carry forward.
|
||||
#[arg(long = "tag")]
|
||||
tags: Vec<String>,
|
||||
/// Clear all `t` tags on update. Mutually exclusive with `--tag`.
|
||||
/// Without this and without `--tag`, existing tags are carried forward.
|
||||
#[arg(long, default_value_t = false)]
|
||||
clear_tags: bool,
|
||||
/// Markdown body. Use `-` to read from stdin.
|
||||
#[arg(long)]
|
||||
content: String,
|
||||
/// Allow committing an empty body (refused by default to catch upstream pipeline failures).
|
||||
#[arg(long, default_value_t = false)]
|
||||
allow_empty: bool,
|
||||
},
|
||||
/// Read a note by `--naddr` (exact) or `--name <slug>` (cross-author lookup).
|
||||
Get {
|
||||
/// NIP-19 `naddr1…` or `30023:<pubkey>:<slug>` coordinate. Mutually exclusive with `--name`.
|
||||
#[arg(long)]
|
||||
naddr: Option<String>,
|
||||
/// Slug to look up across authors. Mutually exclusive with `--naddr`.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Disambiguate `--name` to a specific author (hex pubkey, display name, or `me`).
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Print only the markdown body, not the full event JSON.
|
||||
#[arg(long, default_value_t = false)]
|
||||
content_only: bool,
|
||||
},
|
||||
/// List notes. Defaults to your own.
|
||||
Ls {
|
||||
/// Hex pubkey, display name, `me`, or `all`.
|
||||
#[arg(long, default_value = "me")]
|
||||
author: Option<String>,
|
||||
/// Filter by NIP-23 `t` tag.
|
||||
#[arg(long)]
|
||||
tag: Option<String>,
|
||||
/// Max results (default 50, hard cap 200).
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Delete one of your own notes via NIP-09 (kind:5).
|
||||
///
|
||||
/// Emits an a-tag-only deletion targeting the addressable coordinate
|
||||
/// `30023:<pubkey>:<slug>` (no `e` tag — an `e` tag would route around the
|
||||
/// relay's coordinate soft-delete and leave the note alive). Read-before-
|
||||
/// write gives a clean NotFound when there's nothing to delete.
|
||||
Rm {
|
||||
/// Slug of the note to delete. Only your own notes can be removed.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repos subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -983,6 +1065,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
|
||||
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
|
||||
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
|
||||
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
|
||||
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
|
||||
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
||||
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
|
||||
@@ -1014,6 +1097,7 @@ mod tests {
|
||||
"feed",
|
||||
"mem",
|
||||
"messages",
|
||||
"notes",
|
||||
"pack",
|
||||
"reactions",
|
||||
"repos",
|
||||
|
||||
@@ -550,6 +550,36 @@ pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result<bool> {
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Soft-delete the live row for an addressable coordinate
|
||||
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key.
|
||||
///
|
||||
/// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any
|
||||
/// parameterized-replaceable kind. The WHERE clause mirrors
|
||||
/// `replace_parameterized_event` so the coordinate semantics stay consistent:
|
||||
/// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global
|
||||
/// per the spec — `channel_id` is stored for query scoping, not identity).
|
||||
///
|
||||
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
|
||||
/// (already deleted, or never existed).
|
||||
pub async fn soft_delete_by_coordinate(
|
||||
pool: &PgPool,
|
||||
kind: i32,
|
||||
pubkey: &[u8],
|
||||
d_tag: &str,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE events SET deleted_at = NOW() \
|
||||
WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(kind)
|
||||
.bind(pubkey)
|
||||
.bind(d_tag)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||||
///
|
||||
/// Wraps the delete + counter update in a single transaction so a crash between
|
||||
|
||||
@@ -267,6 +267,17 @@ impl Db {
|
||||
event::soft_delete_event(&self.pool, event_id).await
|
||||
}
|
||||
|
||||
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`.
|
||||
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds.
|
||||
pub async fn soft_delete_by_coordinate(
|
||||
&self,
|
||||
kind: i32,
|
||||
pubkey: &[u8],
|
||||
d_tag: &str,
|
||||
) -> Result<bool> {
|
||||
event::soft_delete_by_coordinate(&self.pool, kind, pubkey, d_tag).await
|
||||
}
|
||||
|
||||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||||
pub async fn soft_delete_event_and_update_thread(
|
||||
&self,
|
||||
|
||||
@@ -7,9 +7,9 @@ use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
|
||||
KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
|
||||
event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS,
|
||||
KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
|
||||
};
|
||||
use sprout_db::channel::MemberRole;
|
||||
|
||||
@@ -104,7 +104,7 @@ pub async fn validate_standard_deletion_event(
|
||||
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
|
||||
let target_ids = extract_target_event_ids(event);
|
||||
|
||||
if target_ids.is_empty() {
|
||||
if !has_e_tag(event) {
|
||||
// a-tag deletion: verify author owns the addressable event
|
||||
let a_tag = event
|
||||
.tags
|
||||
@@ -1380,11 +1380,53 @@ async fn handle_a_tag_deletion(event: &Event, state: &Arc<AppState>) -> anyhow::
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic NIP-33 (parameterized-replaceable) soft-delete by coordinate.
|
||||
//
|
||||
// Listed after the workflow branch so workflow's bespoke deletion
|
||||
// (which doesn't soft-delete the `events` row by design — that's a
|
||||
// separate concern) takes precedence. For every other addressable
|
||||
// kind, including kind:30023 (NIP-23 long-form), we soft-delete the
|
||||
// live row matching `(kind, pubkey, d_tag)` so REQs stop returning it.
|
||||
// See https://github.com/block/sprout/issues/714.
|
||||
k if is_parameterized_replaceable(k) => {
|
||||
let pubkey_bytes = match hex::decode(pubkey_hex) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"invalid pubkey hex in a-tag {pubkey_hex}: {e}"
|
||||
));
|
||||
}
|
||||
};
|
||||
// Safe cast: NIP-33 kinds are 30000–39999, well within i32.
|
||||
let kind_i32 = k as i32;
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_by_coordinate(kind_i32, &pubkey_bytes, d_tag)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to soft-delete by coordinate {kind_i32}:{pubkey_hex}:{d_tag}: {e}"
|
||||
)
|
||||
})?;
|
||||
if deleted {
|
||||
tracing::info!(
|
||||
kind = k,
|
||||
d_tag = d_tag,
|
||||
"NIP-09 a-tag deletion: soft-deleted addressable event by coordinate"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
kind = k,
|
||||
d_tag = d_tag,
|
||||
"NIP-09 a-tag deletion: no live row matched coordinate"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
kind = kind_num,
|
||||
d_tag = d_tag,
|
||||
"NIP-09 a-tag deletion for unhandled kind — no side effect"
|
||||
"NIP-09 a-tag deletion for non-NIP-33 kind — no side effect"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1397,8 +1439,10 @@ async fn handle_standard_deletion_event(
|
||||
state: &Arc<AppState>,
|
||||
) -> anyhow::Result<()> {
|
||||
let target_ids = extract_target_event_ids(event);
|
||||
if target_ids.is_empty() {
|
||||
// NIP-09 a-tag deletion path for addressable events
|
||||
if !has_e_tag(event) {
|
||||
// NIP-09 a-tag deletion path for addressable events. Keyed on the
|
||||
// absence of *any* e tag (not just valid e-ids): a malformed e + a must
|
||||
// not route here and silently soft-delete the coordinate.
|
||||
return handle_a_tag_deletion(event, state).await;
|
||||
}
|
||||
|
||||
@@ -1553,6 +1597,15 @@ fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> V
|
||||
event.pubkey.serialize().to_vec()
|
||||
}
|
||||
|
||||
/// True if the event carries any `e` tag at all, regardless of whether its
|
||||
/// value decodes to a valid 32-byte id. NIP-09 treats `e`/`a` as target
|
||||
/// classes: a malformed `e` makes the deletion ambiguous, not addressable-only.
|
||||
/// Routing keys on this rather than on decoded-target count so a malformed `e`
|
||||
/// alongside an `a` never silently soft-deletes a coordinate.
|
||||
fn has_e_tag(event: &Event) -> bool {
|
||||
event.tags.iter().any(|t| t.kind().to_string() == "e")
|
||||
}
|
||||
|
||||
fn extract_target_event_ids(event: &Event) -> Vec<Vec<u8>> {
|
||||
event
|
||||
.tags
|
||||
|
||||
@@ -317,3 +317,246 @@ async fn test_long_form_stale_write_rejected() {
|
||||
|
||||
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 = SproutTestClient::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,
|
||||
"",
|
||||
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 = SproutTestClient::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,
|
||||
"",
|
||||
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 `sprout-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
|
||||
/// `sprout-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 = SproutTestClient::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",
|
||||
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_u64();
|
||||
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_u64(),
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user