mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
Implement event re-evaluation engine for multi-maintainer scenarios
This commit is contained in:
@@ -8,11 +8,13 @@
|
||||
/// - `DeletionPolicy` - NIP-09 deletion request validation
|
||||
/// - `deletion_ops` - Event dependency queries and database migration
|
||||
/// - `graph` - Event dependency graph for multi-maintainer deletion scenarios
|
||||
/// - `reevaluation` - Re-evaluation engine for multi-maintainer scenarios
|
||||
mod announcement;
|
||||
mod deletion;
|
||||
mod deletion_ops;
|
||||
mod graph;
|
||||
mod pr_event;
|
||||
mod reevaluation;
|
||||
mod related;
|
||||
mod state;
|
||||
|
||||
@@ -23,6 +25,9 @@ pub use deletion_ops::{
|
||||
};
|
||||
pub use graph::{build_event_graph, EventGraph, EventNode};
|
||||
pub use pr_event::PrEventPolicy;
|
||||
pub use reevaluation::{
|
||||
reevaluate_events_without_announcement, ReevaluationResult, RetentionReason,
|
||||
};
|
||||
pub use related::{ReferenceResult, RelatedEventPolicy};
|
||||
pub use state::{StatePolicy, StateResult};
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/// Re-evaluation Engine - Determines event retention after announcement deletion
|
||||
///
|
||||
/// When a repository announcement is deleted, this module re-evaluates all dependent
|
||||
/// events to determine which should be kept (still acceptable through other announcements)
|
||||
/// vs deleted (no longer acceptable).
|
||||
///
|
||||
/// This is critical for multi-maintainer scenarios where one maintainer deletes their
|
||||
/// announcement but other maintainers' announcements should keep the events alive.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr_relay_builder::prelude::{Event, EventId, Filter, Kind};
|
||||
|
||||
use super::SharedDatabase;
|
||||
|
||||
/// Result of re-evaluating events after an announcement deletion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReevaluationResult {
|
||||
/// Events that should be KEPT (still acceptable through other announcements)
|
||||
pub keep: HashMap<EventId, RetentionReason>,
|
||||
/// Events that should be DELETED (no longer acceptable)
|
||||
pub delete: HashSet<EventId>,
|
||||
}
|
||||
|
||||
/// Reason why an event is being kept after re-evaluation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetentionReason {
|
||||
/// Event ID being retained
|
||||
pub event_id: EventId,
|
||||
/// Announcement addresses that make this event acceptable
|
||||
pub valid_announcements: Vec<String>,
|
||||
/// Event type description for logging
|
||||
pub event_type: String,
|
||||
}
|
||||
|
||||
/// Re-evaluate events that were dependent on a deleted announcement
|
||||
///
|
||||
/// This function determines which events should be kept vs deleted when an
|
||||
/// announcement is removed. It checks if events have alternative retention
|
||||
/// reasons through other valid announcements.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `database` - Database to query for announcements and events
|
||||
/// * `deleted_address` - Address of the deleted announcement (format: `30617:<pubkey>:<identifier>`)
|
||||
/// * `potentially_affected` - Event IDs that might be affected by the deletion
|
||||
///
|
||||
/// # Returns
|
||||
/// `ReevaluationResult` with events to keep (with reasons) and events to delete
|
||||
///
|
||||
/// # Algorithm
|
||||
/// For each potentially affected event:
|
||||
/// 1. Query all active repository announcements (kind 30617) from database
|
||||
/// 2. Extract all `a` tags from the event that reference announcements
|
||||
/// 3. Check if any of those `a` tags reference announcements OTHER than the deleted one
|
||||
/// 4. If YES: event is KEPT (has alternative retention reason)
|
||||
/// 5. If NO: check if event is a repository announcement itself (multi-maintainer case)
|
||||
/// 6. If repository announcement: check if other maintainers have announcements for same identifier
|
||||
/// 7. If YES: event is KEPT (other maintainers exist)
|
||||
/// 8. If NO: event is DELETED (no valid announcements remain)
|
||||
pub async fn reevaluate_events_without_announcement(
|
||||
database: &SharedDatabase,
|
||||
deleted_address: &str,
|
||||
potentially_affected: &HashSet<EventId>,
|
||||
) -> Result<ReevaluationResult, String> {
|
||||
let mut result = ReevaluationResult {
|
||||
keep: HashMap::new(),
|
||||
delete: HashSet::new(),
|
||||
};
|
||||
|
||||
// Parse the deleted address to get identifier
|
||||
let deleted_identifier = parse_identifier_from_address(deleted_address)?;
|
||||
|
||||
// Query all active repository announcements (kind 30617) for this identifier
|
||||
let active_announcements = query_active_announcements(database, &deleted_identifier).await?;
|
||||
|
||||
// Build a set of valid announcement addresses (excluding the deleted one)
|
||||
let valid_addresses: HashSet<String> = active_announcements
|
||||
.iter()
|
||||
.map(|event| build_announcement_address(event))
|
||||
.filter(|addr| addr != deleted_address)
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
deleted_address = %deleted_address,
|
||||
identifier = %deleted_identifier,
|
||||
active_count = active_announcements.len(),
|
||||
valid_count = valid_addresses.len(),
|
||||
"Re-evaluating {} events after announcement deletion",
|
||||
potentially_affected.len()
|
||||
);
|
||||
|
||||
// Re-evaluate each potentially affected event
|
||||
for event_id in potentially_affected {
|
||||
// Query the event from database
|
||||
let filter = Filter::new().id(*event_id);
|
||||
let events: Vec<Event> = database
|
||||
.query(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Database query failed: {}", e))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if events.is_empty() {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
"Event not found in database during re-evaluation, marking for deletion (fail-secure)"
|
||||
);
|
||||
// If event is not in database, mark it for deletion (fail-secure)
|
||||
result.delete.insert(*event_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = &events[0];
|
||||
|
||||
// Determine if this event should be kept or deleted
|
||||
match evaluate_event_retention(event, &deleted_identifier, &valid_addresses).await {
|
||||
Ok(Some(reason)) => {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
event_type = %reason.event_type,
|
||||
valid_announcements = ?reason.valid_announcements,
|
||||
"Event KEPT: has alternative retention reason"
|
||||
);
|
||||
result.keep.insert(*event_id, reason);
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
event_id = %event_id,
|
||||
kind = %event.kind,
|
||||
"Event DELETED: no valid announcements remain"
|
||||
);
|
||||
result.delete.insert(*event_id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
event_id = %event_id,
|
||||
error = %e,
|
||||
"Error evaluating event retention, marking for deletion (fail-secure)"
|
||||
);
|
||||
result.delete.insert(*event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
deleted_address = %deleted_address,
|
||||
keep_count = result.keep.len(),
|
||||
delete_count = result.delete.len(),
|
||||
"Re-evaluation complete: {} kept, {} deleted",
|
||||
result.keep.len(),
|
||||
result.delete.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Evaluate whether a single event should be retained after announcement deletion
|
||||
///
|
||||
/// Returns Some(RetentionReason) if event should be kept, None if it should be deleted.
|
||||
async fn evaluate_event_retention(
|
||||
event: &Event,
|
||||
_deleted_identifier: &str,
|
||||
valid_addresses: &HashSet<String>,
|
||||
) -> Result<Option<RetentionReason>, String> {
|
||||
let event_type = get_event_type_description(event);
|
||||
|
||||
// Special case: Repository announcements (kind 30617)
|
||||
// These are kept if there are OTHER announcements for the same identifier
|
||||
if event.kind == Kind::GitRepoAnnouncement {
|
||||
let event_address = build_announcement_address(event);
|
||||
|
||||
// If this IS the deleted announcement, it should be deleted
|
||||
if !valid_addresses.contains(&event_address) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// This is a different announcement for the same identifier - keep it
|
||||
return Ok(Some(RetentionReason {
|
||||
event_id: event.id,
|
||||
valid_announcements: vec![event_address],
|
||||
event_type,
|
||||
}));
|
||||
}
|
||||
|
||||
// For all other events: check if they have `a` tags referencing valid announcements
|
||||
let referenced_announcements = extract_announcement_references(event);
|
||||
|
||||
#[cfg(test)]
|
||||
println!(
|
||||
"Event {}: referenced {} announcements, {} valid addresses",
|
||||
event.id,
|
||||
referenced_announcements.len(),
|
||||
valid_addresses.len()
|
||||
);
|
||||
|
||||
// Filter to only valid announcements (excluding deleted one)
|
||||
let valid_refs: Vec<String> = referenced_announcements
|
||||
.into_iter()
|
||||
.filter(|addr| valid_addresses.contains(addr))
|
||||
.collect();
|
||||
|
||||
if valid_refs.is_empty() {
|
||||
// No valid announcement references - event should be deleted
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
"No valid announcement references found"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Event has valid announcement references - keep it
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
valid_refs_count = valid_refs.len(),
|
||||
"Event has valid announcement references, keeping"
|
||||
);
|
||||
Ok(Some(RetentionReason {
|
||||
event_id: event.id,
|
||||
valid_announcements: valid_refs,
|
||||
event_type,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Query all active repository announcements for a given identifier
|
||||
///
|
||||
/// Returns all kind 30617 events with matching `d` tag (identifier).
|
||||
async fn query_active_announcements(
|
||||
database: &SharedDatabase,
|
||||
identifier: &str,
|
||||
) -> Result<Vec<Event>, String> {
|
||||
// Query all repository announcements
|
||||
let filter = Filter::new().kind(Kind::GitRepoAnnouncement);
|
||||
|
||||
let all_announcements: Vec<Event> = database
|
||||
.query(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Database query failed: {}", e))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Filter by identifier manually (some databases don't support custom_tag queries properly)
|
||||
let events: Vec<Event> = all_announcements
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
event.tags.iter().any(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == identifier
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Extract all announcement references (`a` tags) from an event
|
||||
///
|
||||
/// Returns addresses in format: `30617:<pubkey>:<identifier>`
|
||||
fn extract_announcement_references(event: &Event) -> Vec<String> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "a" {
|
||||
let address = &tag_vec[1];
|
||||
// Only include kind 30617 (repository announcement) references
|
||||
if address.starts_with("30617:") {
|
||||
refs.push(address.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refs
|
||||
}
|
||||
|
||||
/// Build announcement address from event
|
||||
///
|
||||
/// Format: `30617:<pubkey>:<identifier>`
|
||||
fn build_announcement_address(event: &Event) -> String {
|
||||
let identifier = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
||||
Some(tag_vec[1].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
format!("30617:{}:{}", event.pubkey.to_hex(), identifier)
|
||||
}
|
||||
|
||||
/// Parse identifier from announcement address
|
||||
///
|
||||
/// Address format: `30617:<pubkey>:<identifier>`
|
||||
fn parse_identifier_from_address(address: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = address.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid address format: {}", address));
|
||||
}
|
||||
|
||||
if parts[0] != "30617" {
|
||||
return Err(format!(
|
||||
"Not a repository announcement address: {}",
|
||||
address
|
||||
));
|
||||
}
|
||||
|
||||
Ok(parts[2].to_string())
|
||||
}
|
||||
|
||||
/// Get human-readable event type description for logging
|
||||
fn get_event_type_description(event: &Event) -> String {
|
||||
match event.kind {
|
||||
Kind::GitRepoAnnouncement => "Repository Announcement".to_string(),
|
||||
Kind::RepoState => "Repository State".to_string(),
|
||||
Kind::GitPullRequest => "Pull Request".to_string(),
|
||||
Kind::GitPullRequestUpdate => "PR Update".to_string(),
|
||||
kind if kind == Kind::from(1621) => "Issue".to_string(),
|
||||
kind if kind == Kind::from(1617) => "Patch".to_string(),
|
||||
kind if kind == Kind::from(1630) => "Issue Status".to_string(),
|
||||
kind if kind == Kind::from(1631) => "PR Status".to_string(),
|
||||
kind if kind == Kind::from(1632) => "Patch Status".to_string(),
|
||||
kind if kind == Kind::from(1633) => "Repository Status".to_string(),
|
||||
_ => format!("Event (kind {})", event.kind.as_u16()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_relay_builder::prelude::{
|
||||
EventBuilder, Keys, MemoryDatabase, MemoryDatabaseOptions, NostrDatabase, Tag, TagKind,
|
||||
};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Helper to create a test repository announcement
|
||||
fn create_announcement(keys: &Keys, identifier: &str, domain: &str) -> Event {
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("d"), vec![identifier.to_string()]),
|
||||
Tag::custom(
|
||||
TagKind::custom("clone"),
|
||||
vec![format!("https://{}/{}", domain, identifier)],
|
||||
),
|
||||
Tag::custom(TagKind::custom("relays"), vec![format!("wss://{}", domain)]),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper to create a test issue event
|
||||
fn create_issue(keys: &Keys, announcement_address: &str) -> Event {
|
||||
EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("a"),
|
||||
vec![announcement_address.to_string()],
|
||||
)])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper to create a test patch event
|
||||
fn create_patch(keys: &Keys, announcement_address: &str) -> Event {
|
||||
EventBuilder::new(Kind::from(1617), "Test patch")
|
||||
.tags(vec![Tag::custom(
|
||||
TagKind::custom("a"),
|
||||
vec![announcement_address.to_string()],
|
||||
)])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_maintainer_all_deleted() {
|
||||
// Setup: Single maintainer with announcement and dependent events
|
||||
let keys = Keys::generate();
|
||||
let identifier = "test-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement = create_announcement(&keys, identifier, domain);
|
||||
let announcement_addr = build_announcement_address(&announcement);
|
||||
|
||||
let issue = create_issue(&keys, &announcement_addr);
|
||||
let patch = create_patch(&keys, &announcement_addr);
|
||||
|
||||
// Create in-memory database and store events
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
db.save_event(&patch).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting the only announcement
|
||||
let affected = HashSet::from([issue.id, patch.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &announcement_addr, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// All events should be deleted (no other announcements)
|
||||
assert_eq!(result.keep.len(), 0);
|
||||
assert_eq!(result.delete.len(), 2);
|
||||
assert!(result.delete.contains(&issue.id));
|
||||
assert!(result.delete.contains(&patch.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_maintainers_one_deletes() {
|
||||
// Setup: Two maintainers with announcements for same repository
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let identifier = "shared-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
// Create issue referencing both announcements
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr1.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr2.clone()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer1)
|
||||
.unwrap();
|
||||
|
||||
// Create patch referencing only first announcement
|
||||
let patch = create_patch(&maintainer1, &addr1);
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
db.save_event(&patch).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting first announcement
|
||||
let affected = HashSet::from([issue.id, patch.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be kept (references announcement2)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&issue.id));
|
||||
let issue_reason = result.keep.get(&issue.id).unwrap();
|
||||
assert_eq!(issue_reason.valid_announcements.len(), 1);
|
||||
assert_eq!(issue_reason.valid_announcements[0], addr2);
|
||||
|
||||
// Patch should be deleted (only referenced announcement1)
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&patch.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_announcement_references() {
|
||||
// Setup: Event with multiple announcement references
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let maintainer3 = Keys::generate();
|
||||
let identifier = "multi-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let announcement3 = create_announcement(&maintainer3, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let addr2 = build_announcement_address(&announcement2);
|
||||
let addr3 = build_announcement_address(&announcement3);
|
||||
|
||||
// Create issue referencing all three announcements
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Test issue")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec![addr1.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr2.clone()]),
|
||||
Tag::custom(TagKind::custom("a"), vec![addr3.clone()]),
|
||||
])
|
||||
.sign_with_keys(&maintainer1)
|
||||
.unwrap();
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
db.save_event(&announcement3).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting first announcement
|
||||
let affected = HashSet::from([issue.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be kept (still has announcement2 and announcement3)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&issue.id));
|
||||
let reason = result.keep.get(&issue.id).unwrap();
|
||||
assert_eq!(reason.valid_announcements.len(), 2);
|
||||
assert!(reason.valid_announcements.contains(&addr2));
|
||||
assert!(reason.valid_announcements.contains(&addr3));
|
||||
assert!(!reason.valid_announcements.contains(&addr1));
|
||||
|
||||
assert_eq!(result.delete.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_valid_announcements() {
|
||||
// Setup: Event with no valid announcement references
|
||||
let keys = Keys::generate();
|
||||
let identifier = "orphan-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement = create_announcement(&keys, identifier, domain);
|
||||
let announcement_addr = build_announcement_address(&announcement);
|
||||
|
||||
// Create issue with no announcement references
|
||||
let issue = EventBuilder::new(Kind::from(1621), "Orphan issue")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement).await.unwrap();
|
||||
db.save_event(&issue).await.unwrap();
|
||||
|
||||
// Re-evaluate after deleting announcement
|
||||
let affected = HashSet::from([issue.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &announcement_addr, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Issue should be deleted (no announcement references)
|
||||
assert_eq!(result.keep.len(), 0);
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&issue.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repository_announcement_deletion() {
|
||||
// Setup: Multiple announcements for same identifier
|
||||
let maintainer1 = Keys::generate();
|
||||
let maintainer2 = Keys::generate();
|
||||
let identifier = "shared-repo";
|
||||
let domain = "relay.example.com";
|
||||
|
||||
let announcement1 = create_announcement(&maintainer1, identifier, domain);
|
||||
let announcement2 = create_announcement(&maintainer2, identifier, domain);
|
||||
let addr1 = build_announcement_address(&announcement1);
|
||||
let _addr2 = build_announcement_address(&announcement2);
|
||||
|
||||
// Store in database
|
||||
let db: Arc<dyn NostrDatabase> =
|
||||
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
|
||||
}));
|
||||
db.save_event(&announcement1).await.unwrap();
|
||||
db.save_event(&announcement2).await.unwrap();
|
||||
|
||||
// Re-evaluate announcements after deleting first one
|
||||
let affected = HashSet::from([announcement1.id, announcement2.id]);
|
||||
let result = reevaluate_events_without_announcement(&db, &addr1, &affected)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// announcement2 should be kept (different maintainer)
|
||||
assert_eq!(result.keep.len(), 1);
|
||||
assert!(result.keep.contains_key(&announcement2.id));
|
||||
|
||||
// announcement1 should be deleted (it's the deleted one)
|
||||
assert_eq!(result.delete.len(), 1);
|
||||
assert!(result.delete.contains(&announcement1.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_from_address() {
|
||||
let address = "30617:abc123:my-repo";
|
||||
let identifier = parse_identifier_from_address(address).unwrap();
|
||||
assert_eq!(identifier, "my-repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_invalid_format() {
|
||||
let address = "invalid";
|
||||
let result = parse_identifier_from_address(address);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_identifier_wrong_kind() {
|
||||
let address = "30618:abc123:my-repo";
|
||||
let result = parse_identifier_from_address(address);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_announcement_references() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::from(1621), "Test")
|
||||
.tags(vec![
|
||||
Tag::custom(TagKind::custom("a"), vec!["30617:abc:repo1".to_string()]),
|
||||
Tag::custom(TagKind::custom("a"), vec!["30617:def:repo2".to_string()]),
|
||||
Tag::custom(TagKind::custom("a"), vec!["30618:ghi:state".to_string()]), // Wrong kind
|
||||
Tag::custom(TagKind::custom("e"), vec!["event123".to_string()]), // Not an 'a' tag
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let refs = extract_announcement_references(&event);
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert!(refs.contains(&"30617:abc:repo1".to_string()));
|
||||
assert!(refs.contains(&"30617:def:repo2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_announcement_address() {
|
||||
let keys = Keys::generate();
|
||||
let event = create_announcement(&keys, "my-repo", "example.com");
|
||||
let address = build_announcement_address(&event);
|
||||
|
||||
assert!(address.starts_with("30617:"));
|
||||
assert!(address.ends_with(":my-repo"));
|
||||
assert_eq!(address.split(':').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_event_type_description() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_event_type_description(&announcement),
|
||||
"Repository Announcement"
|
||||
);
|
||||
|
||||
let issue = EventBuilder::new(Kind::from(1621), "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(get_event_type_description(&issue), "Issue");
|
||||
|
||||
let pr = EventBuilder::new(Kind::GitPullRequest, "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(get_event_type_description(&pr), "Pull Request");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user