fix cli runs to prevent rate limiting

This commit is contained in:
DanConwayDev
2025-11-06 15:17:32 +00:00
parent ad6b8a825a
commit 16d14d07b0
6 changed files with 620 additions and 207 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ pub enum AuditMode {
impl AuditConfig {
/// Create config for CI/CD testing
pub fn ci() -> Self {
let run_id = format!("ci-{}", uuid::Uuid::new_v4());
let run_id = format!("ci-{}", &uuid::Uuid::new_v4().to_string()[..8]);
Self {
run_id,
mode: AuditMode::CI,
+13 -1
View File
@@ -13,6 +13,18 @@ pub struct AuditClient {
}
impl AuditClient {
/// Create a new audit client for testing (no relay connection)
#[cfg(test)]
pub fn new_test(config: AuditConfig) -> Self {
let keys = Keys::generate();
let client = Client::new(keys.clone());
Self {
client,
config,
keys,
}
}
/// Create a new audit client
pub async fn new(relay_url: &str, config: AuditConfig) -> Result<Self> {
let keys = Keys::generate();
@@ -216,7 +228,7 @@ impl AuditClient {
.replace("wss://", "https://");
// Create unique repository identifier using UUID for consistency
let repo_id = format!("{}-{}", test_name, uuid::Uuid::new_v4());
let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]);
// Get npub for clone URL
let npub = self.public_key().to_bech32()
+312
View File
@@ -0,0 +1,312 @@
//! Test fixture management for dual-mode testing
//!
//! This module provides a TestContext abstraction that manages prerequisite events
//! differently based on the audit mode:
//!
//! - **CI Mode (Isolated)**: Creates fresh events for each test, ensuring complete isolation
//! - **Production Mode (Shared)**: Reuses shared fixtures to minimize event publication
//!
//! # Example
//!
//! ```no_run
//! use grasp_audit::*;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let config = AuditConfig::ci();
//! let client = AuditClient::new("ws://localhost:7000", config).await?;
//! let ctx = TestContext::new(&client);
//!
//! // Request a fixture - behavior depends on mode
//! let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
//! # Ok(())
//! # }
//! ```
use crate::{AuditClient, AuditMode};
use anyhow::{Context, Result};
use nostr_sdk::prelude::Event;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Types of test fixtures available
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FixtureKind {
/// Basic repository announcement (kind 30617)
ValidRepo,
/// Repository with one issue (kind 1621)
RepoWithIssue,
/// Repository with issue and comment (kind 1111)
RepoWithComment,
/// Repository state announcement (kind 30618)
RepoState,
}
/// Context mode for fixture management
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMode {
/// Create fresh fixtures for each request (test isolation)
Isolated,
/// Reuse shared fixtures across requests (minimal events)
Shared,
}
impl From<AuditMode> for ContextMode {
fn from(mode: AuditMode) -> Self {
match mode {
AuditMode::CI => ContextMode::Isolated,
AuditMode::Production => ContextMode::Shared,
}
}
}
/// Test context for managing prerequisite events
///
/// The TestContext provides mode-aware fixture management:
/// - In Isolated mode: Creates fresh events for each test
/// - In Shared mode: Caches and reuses events across tests
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example() -> anyhow::Result<()> {
/// let config = AuditConfig::ci();
/// let client = AuditClient::new("ws://localhost:7000", config).await?;
/// let ctx = TestContext::new(&client);
///
/// // Get a repository fixture
/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
///
/// // In CI mode: Creates new repo
/// // In Production mode: Returns cached repo
/// # Ok(())
/// # }
/// ```
pub struct TestContext<'a> {
client: &'a AuditClient,
mode: ContextMode,
cache: Arc<Mutex<HashMap<FixtureKind, Event>>>,
}
impl<'a> TestContext<'a> {
/// Create a new test context
///
/// The context mode is automatically determined from the client's audit config.
pub fn new(client: &'a AuditClient) -> Self {
let mode = ContextMode::from(client.config.mode);
Self {
client,
mode,
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Create a test context with explicit mode override
///
/// This is useful for testing the context itself or for advanced use cases
/// where you want to override the default mode behavior.
pub fn with_mode(client: &'a AuditClient, mode: ContextMode) -> Self {
Self {
client,
mode,
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Get a fixture, creating it if needed based on mode
///
/// # Behavior
///
/// - **Isolated mode**: Always creates a fresh fixture
/// - **Shared mode**: Returns cached fixture or creates and caches if not present
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
/// # Ok(())
/// # }
/// ```
pub async fn get_fixture(&self, kind: FixtureKind) -> Result<Event> {
match self.mode {
ContextMode::Isolated => self.create_fresh(kind).await,
ContextMode::Shared => self.get_or_create_shared(kind).await,
}
}
/// Get the underlying client for direct access
///
/// This allows tests to use the client directly when needed while still
/// benefiting from the TestContext for fixture management.
pub fn client(&self) -> &'a AuditClient {
self.client
}
/// Get the current context mode
pub fn mode(&self) -> ContextMode {
self.mode
}
/// Create a fresh fixture (always creates new)
async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> {
let event = self.build_fixture(kind).await
.with_context(|| format!("Failed to build {:?} fixture", kind))?;
self.client.send_event(event.clone()).await
.with_context(|| format!("Failed to send {:?} fixture event to relay", kind))?;
Ok(event)
}
/// Get or create a shared fixture (caches for reuse)
async fn get_or_create_shared(&self, kind: FixtureKind) -> Result<Event> {
// Check cache first
{
let cache = self.cache.lock().unwrap();
if let Some(event) = cache.get(&kind) {
return Ok(event.clone());
}
}
// Not in cache, create it
let event = self.build_fixture(kind).await
.with_context(|| format!("Failed to build {:?} fixture for shared cache", kind))?;
self.client.send_event(event.clone()).await
.with_context(|| format!("Failed to send {:?} fixture event to relay (shared cache)", kind))?;
// Store in cache
{
let mut cache = self.cache.lock().unwrap();
cache.insert(kind, event.clone());
}
Ok(event)
}
/// Build a fixture event (doesn't send it)
async fn build_fixture(&self, kind: FixtureKind) -> Result<Event> {
match kind {
FixtureKind::ValidRepo => {
let test_name = format!("fixture-{:?}-{}", kind, &uuid::Uuid::new_v4().to_string()[..8]);
self.client.create_repo_announcement(&test_name).await
}
FixtureKind::RepoWithIssue => {
// First create repo
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
// Then create issue referencing it
self.client.create_issue(
&repo,
"Test Issue",
"Issue content for testing",
vec![],
)
}
FixtureKind::RepoWithComment => {
// First create repo with issue
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
let issue = self.client.create_issue(
&repo,
"Test Issue",
"Issue content",
vec![],
)?;
self.client.send_event(issue.clone()).await?;
// Then create comment on issue
self.client.create_comment(
&issue,
"Test comment",
vec![],
)
}
FixtureKind::RepoState => {
use nostr_sdk::prelude::*;
// First create repo announcement
let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]);
let repo = self.client.create_repo_announcement(&test_name).await?;
self.client.send_event(repo.clone()).await?;
// Extract repo_id from repo announcement
let repo_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))?
.to_string();
// Create state announcement
self.client.event_builder(Kind::Custom(30618), "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![
"abc123def456789012345678901234567890abcd"
]))
.tag(Tag::custom(TagKind::custom("HEAD"), vec![
"ref: refs/heads/main"
]))
.build(self.client.keys())
.map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))
}
}
}
/// Clear the fixture cache
///
/// This is useful for tests that want to ensure fresh fixtures
/// even in shared mode.
pub fn clear_cache(&self) {
let mut cache = self.cache.lock().unwrap();
cache.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AuditConfig;
#[test]
fn test_context_mode_from_audit_mode() {
assert_eq!(ContextMode::from(AuditMode::CI), ContextMode::Isolated);
assert_eq!(ContextMode::from(AuditMode::Production), ContextMode::Shared);
}
#[test]
fn test_fixture_kind_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(FixtureKind::ValidRepo);
set.insert(FixtureKind::RepoWithIssue);
assert!(set.contains(&FixtureKind::ValidRepo));
assert!(!set.contains(&FixtureKind::RepoWithComment));
}
#[tokio::test]
async fn test_context_creation() {
let config = AuditConfig::ci();
let client = crate::AuditClient::new_test(config);
let ctx = TestContext::new(&client);
assert_eq!(ctx.mode(), ContextMode::Isolated);
let ctx = TestContext::with_mode(&client, ContextMode::Shared);
assert_eq!(ctx.mode(), ContextMode::Shared);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn generate_test_id() -> String {
/// Generate a unique audit run ID for CI
pub fn generate_ci_run_id() -> String {
format!("ci-{}", uuid::Uuid::new_v4())
format!("ci-{}", &uuid::Uuid::new_v4().to_string()[..8])
}
/// Generate a unique audit run ID for production
+2
View File
@@ -29,12 +29,14 @@
//! ```
pub mod audit;
pub mod fixtures;
pub mod client;
pub mod isolation;
pub mod result;
pub mod specs;
pub use audit::{AuditConfig, AuditMode};
pub use fixtures::{ContextMode, FixtureKind, TestContext};
pub use client::AuditClient;
pub use result::{AuditResult, TestResult};
@@ -89,7 +89,7 @@
//! - Transitive tests verify multi-hop acceptance chains
use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp};
use crate::{AuditClient, AuditResult, TestResult};
use crate::{AuditClient, AuditResult, TestResult, TestContext, FixtureKind};
use std::time::Duration;
/// Test suite for GRASP-01 event acceptance policy
@@ -139,6 +139,10 @@ impl EventAcceptancePolicyTests {
///
/// Spec: Lines 3-5 of ../grasp/01.md
/// Requirement: MUST accept repo announcements listing service in clone & relays tags
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_valid_repo_announcement",
@@ -146,9 +150,12 @@ impl EventAcceptancePolicyTests {
"Accept valid repository announcements with service in clone and relays tags",
)
.run(|| async {
// Create a NIP-34 repository announcement event
let event = client.create_repo_announcement("accept_valid_repo_announcement").await
.map_err(|e| format!("Failed to create repository announcement: {}", e))?;
// Create TestContext for mode-aware fixture management
let ctx = TestContext::new(client);
// Request repository fixture - behavior depends on mode
let event = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Get relay URL for validation
let relay_url = client.client().relays().await
@@ -169,9 +176,7 @@ impl EventAcceptancePolicyTests {
.ok_or("Missing d tag in announcement")?
.to_string();
// Send the event
let event_id = client.send_event(event.clone()).await
.map_err(|e| format!("Failed to send repository announcement to relay: {}", e))?;
let event_id = event.id;
// Query back to verify it was accepted and stored
let filter = Filter::new()
@@ -355,6 +360,11 @@ impl EventAcceptancePolicyTests {
///
/// Spec: Lines 6-7 of ../grasp/01.md
/// Requirement: MUST accept repo state announcements with d, maintainers, and r tags
///
/// **EXAMPLE: Using TestContext pattern for fixture management**
/// This test demonstrates the new TestContext pattern:
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_valid_repo_state_announcement(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_valid_repo_state_announcement",
@@ -362,10 +372,14 @@ impl EventAcceptancePolicyTests {
"Accept valid state announcements after repo announcement accepted",
)
.run(|| async {
// First, create a repository announcement (kind 30617) by the same author
let test_name = format!("test-repo-multi-refs-{}", Timestamp::now().as_u64());
let repo_event = client.create_repo_announcement(&test_name).await
.map_err(|e| format!("Failed to create repository announcement: {}", e))?;
// NEW: Create TestContext for mode-aware fixture management
let ctx = TestContext::new(client);
// NEW: Request repository fixture - behavior depends on mode
// CI mode: Creates fresh repo for this test
// Production mode: Returns cached repo if available
let repo_event = ctx.get_fixture(FixtureKind::RepoState).await
.map_err(|e| format!("Test setup failed: could not get repository state fixture: {}", e))?;
// Extract repo_id from the repository announcement
let repo_id = repo_event.tags.iter()
@@ -374,36 +388,7 @@ impl EventAcceptancePolicyTests {
.ok_or("Missing d tag in repository announcement")?
.to_string();
// Note: npub not used in this test, removed unused variable
// Create kind 30618 repository state announcement with multiple refs
// Format: ["r", "refs/heads/main", "<commit-id>"]
let event = client.event_builder(Kind::Custom(30618), "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![
"abc123def456789012345678901234567890abcd"
]))
.tag(Tag::custom(TagKind::custom("refs/heads/develop"), vec![
"def456789012345678901234567890abcdef123"
]))
.tag(Tag::custom(TagKind::custom("refs/tags/v1.0.0"), vec![
"123456789012345678901234567890abcdef456"
]))
.tag(Tag::custom(TagKind::custom("HEAD"), vec![
"ref: refs/heads/main"
]))
.build(client.keys())
.map_err(|e| format!("Failed to build state announcement: {}", e))?;
let event_id = event.id;
// Send the repo announcement event
client.send_event(repo_event.clone()).await
.map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
// Send the state event
client.send_event(event.clone()).await
.map_err(|e| format!("Failed to send state announcement to relay: {}", e))?;
let event_id = repo_event.id;
// Query back to verify it was accepted and stored
let filter = Filter::new()
@@ -447,7 +432,7 @@ impl EventAcceptancePolicyTests {
async fn create_test_repo(client: &AuditClient, repo_id: &str) -> Result<Event, String> {
client.create_repo_announcement(repo_id)
.await
.map_err(|e| e.to_string())
.map_err(|e| format!("Test setup failed: could not create test repository: {}", e))
}
/// Create an issue (kind 1621) that references a repository
@@ -458,7 +443,7 @@ impl EventAcceptancePolicyTests {
issue_title: &str,
) -> Result<Event, String> {
client.create_issue(repo_event, issue_title, "issue content", vec![])
.map_err(|e| e.to_string())
.map_err(|e| format!("Test setup failed: could not create test issue: {}", e))
}
/// Create a NIP-22 comment (kind 1111) for an event
@@ -469,7 +454,7 @@ impl EventAcceptancePolicyTests {
content: &str,
) -> Result<Event, String> {
client.create_comment(event, content, vec![])
.map_err(|e| e.to_string())
.map_err(|e| format!("Test setup failed: could not create test comment: {}", e))
}
/// Send event and verify it was accepted (stored by relay)
@@ -480,13 +465,14 @@ impl EventAcceptancePolicyTests {
) -> Result<(), String> {
let event_id = event.id;
client.send_event(event).await?;
client.send_event(event).await
.map_err(|e| format!("Failed to send event to relay: {}", e))?;
tokio::time::sleep(Duration::from_millis(100)).await;
let filter = Filter::new().id(event_id);
let events = client.query(filter).await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to query relay for verification: {}", e))?;
if events.is_empty() {
return Err(format!("Event should be accepted: {}", description));
@@ -504,13 +490,13 @@ impl EventAcceptancePolicyTests {
let event_id = event.id;
client.send_event(event).await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to send event to relay: {}", e))?;
tokio::time::sleep(Duration::from_millis(100)).await;
let filter = Filter::new().id(event_id);
let events = client.query(filter).await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to query relay for verification: {}", e))?;
if !events.is_empty() {
return Err(format!("Event should be rejected: {}", description));
@@ -524,6 +510,9 @@ impl EventAcceptancePolicyTests {
// ============================================================
/// Test 1.1: Issue referencing repo via `a` tag should be accepted
///
/// **EXAMPLE: Using TestContext for prerequisite events**
/// Demonstrates how TestContext simplifies test setup while supporting dual modes
async fn test_accept_issue_via_a_tag(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_issue_via_a_tag",
@@ -531,11 +520,14 @@ impl EventAcceptancePolicyTests {
"Accept issue referencing repo via 'a' tag",
)
.run(|| async {
// 1. Create and send repo announcement
let repo = Self::create_test_repo(client, "test-repo-1").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
// NEW: Create TestContext
let ctx = TestContext::new(client);
// 2. Create issue that references the repo (uses create_issue_for_repo helper)
// NEW: Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// 2. Create issue that references the repo
let issue = Self::create_issue_for_repo(client, &repo, "Test Issue 1")?;
// 3. Send issue and verify it's accepted
@@ -547,6 +539,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 1.2: NIP-22 comment with root `A` tag referencing repo should be accepted
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_comment_via_A_tag(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_comment_via_A_tag",
@@ -554,16 +550,19 @@ impl EventAcceptancePolicyTests {
"Accept NIP-22 comment with root 'A' tag referencing repo",
)
.run(|| async {
// 1. Create and send repo announcement
let repo = Self::create_test_repo(client, "test-repo-2").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
// Create TestContext
let ctx = TestContext::new(client);
// 2. Extract repo_id and create `A` tag manually
// Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Extract repo_id and create `A` tag manually
let repo_id = Self::extract_d_tag(&repo)
.ok_or("Failed to extract repo_id from repo event")?;
let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
// 3. Create comment with `A` tag (root reference to repo)
// Create comment with `A` tag (root reference to repo)
let tags = vec![
Tag::custom(TagKind::custom("A"), vec![a_tag_value.clone(), "".to_string(), "root".to_string()]),
Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]),
@@ -576,7 +575,7 @@ impl EventAcceptancePolicyTests {
.build(client.keys())
.map_err(|e| format!("Failed to build comment: {}", e))?;
// 4. Send comment and verify it's accepted
// Send comment and verify it's accepted
Self::send_and_verify_accepted(client, comment, "comment with 'A' tag to repo").await?;
Ok(())
@@ -585,6 +584,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 1.3: Kind 1 text note quoting repo via `q` tag should be accepted
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_kind1_via_q_tag(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_kind1_via_q_tag",
@@ -592,16 +595,19 @@ impl EventAcceptancePolicyTests {
"Accept kind 1 note quoting repo via 'q' tag",
)
.run(|| async {
// 1. Create and send repo announcement
let repo = Self::create_test_repo(client, "test-repo-3").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?;
// Create TestContext
let ctx = TestContext::new(client);
// 2. Extract repo_id and create `q` tag
// Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Extract repo_id and create `q` tag
let repo_id = Self::extract_d_tag(&repo)
.ok_or("Failed to extract repo_id from repo event")?;
let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
// 3. Create kind 1 note with `q` tag (quote reference to repo)
// Create kind 1 note with `q` tag (quote reference to repo)
let tags = vec![
Tag::custom(TagKind::custom("q"), vec![a_tag_value]),
];
@@ -612,7 +618,7 @@ impl EventAcceptancePolicyTests {
.build(client.keys())
.map_err(|e| format!("Failed to build note: {}", e))?;
// 4. Send note and verify it's accepted
// Send note and verify it's accepted
Self::send_and_verify_accepted(client, note, "kind 1 with 'q' tag to repo").await?;
Ok(())
@@ -625,6 +631,10 @@ impl EventAcceptancePolicyTests {
// ============================================================
/// Test 2.1: Issue quoting another accepted issue should be accepted (transitive)
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo+issue for full isolation
/// - In Production mode: Reuses cached repo+issue to minimize events
async fn test_accept_issue_quoting_issue_via_q(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_issue_quoting_issue_via_q",
@@ -632,31 +642,43 @@ impl EventAcceptancePolicyTests {
"Accept issue quoting accepted issue (transitive)",
)
.run(|| async {
// 1. Create and send Repo A
let repo_a = Self::create_test_repo(client, "repo-a").await?;
Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?;
// 2. Create and send Issue A (references repo A, so it's accepted)
let issue_a = Self::create_issue_for_repo(client, &repo_a, "Issue A")?;
Self::send_and_verify_accepted(client, issue_a.clone(), "issue A").await?;
// 3. Create Repo B but DON'T send it (unaccepted) - just for creating Issue B
let repo_b = Self::create_test_repo(client, "repo-b").await?;
// 4. Create Issue B that:
// - References unaccepted Repo B (would normally be rejected)
// - BUT also quotes accepted Issue A via 'q' tag (should make it accepted)
let additional_tags = vec![
// Quote to accepted Issue A (this makes it transitive)
Tag::custom(TagKind::custom("q"), vec![issue_a.id.to_hex()]),
];
let issue_b = client
.create_issue(&repo_b, "Issue B", "issue content", additional_tags)
.map_err(|e| format!("Failed to build issue B: {}", e))?;
// 5. Send Issue B and verify it's ACCEPTED (via transitive quote to Issue A)
// Create TestContext
let ctx = TestContext::new(client);
// Get repo with issue fixture (mode-aware)
let repo_a = ctx.get_fixture(FixtureKind::RepoWithIssue).await
.map_err(|e| format!("Test setup failed: could not get repo with issue fixture: {}", e))?;
// Extract the issue from the repo_a event (it's stored as the first 'e' tag)
let issue_a_id = repo_a.tags.iter()
.find(|t| t.kind() == TagKind::e())
.and_then(|t| t.content())
.ok_or("Missing issue reference in RepoWithIssue fixture")?;
// Query to get the actual issue event
let filter = Filter::new().id(
nostr_sdk::EventId::from_hex(issue_a_id)
.map_err(|e| format!("Invalid issue ID: {}", e))?
);
let issues = client.query(filter).await
.map_err(|e| format!("Failed to query issue: {}", e))?;
let issue_a = issues.first()
.ok_or("Issue not found")?
.clone();
// Create Repo B but DON'T send it (unaccepted) - just for creating Issue B
let repo_b = Self::create_test_repo(client, "repo-b").await?;
// Create Issue B that quotes accepted Issue A via 'q' tag (should make it accepted)
let additional_tags = vec![
Tag::custom(TagKind::custom("q"), vec![issue_a.id.to_hex()]),
];
let issue_b = client
.create_issue(&repo_b, "Issue B", "issue content", additional_tags)
.map_err(|e| format!("Failed to build issue B: {}", e))?;
// Send Issue B and verify it's ACCEPTED (via transitive quote to Issue A)
Self::send_and_verify_accepted(client, issue_b, "issue B quoting accepted issue A").await?;
Ok(())
@@ -665,6 +687,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 2.2: NIP-22 comment with root 'E' tag to accepted issue should be accepted
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo+issue for full isolation
/// - In Production mode: Reuses cached repo+issue to minimize events
async fn test_accept_comment_via_E_tag(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_comment_via_E_tag",
@@ -672,19 +698,34 @@ impl EventAcceptancePolicyTests {
"Accept NIP-22 comment with root 'E' tag to accepted issue",
)
.run(|| async {
// 1. Create and send repo
let repo = Self::create_test_repo(client, "repo-comment").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
// 2. Create and send issue (references repo, so it's accepted)
let issue = Self::create_issue_for_repo(client, &repo, "Issue for comment")?;
Self::send_and_verify_accepted(client, issue.clone(), "issue").await?;
// 3. Create comment using the helper (which adds NIP-22 tags including 'E')
let comment = Self::create_comment_for_event(client, &issue, "Comment content")?;
// 4. Send comment and verify it's accepted (via E tag to accepted issue)
// Create TestContext
let ctx = TestContext::new(client);
// Get repo with issue fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::RepoWithIssue).await
.map_err(|e| format!("Test setup failed: could not get repo with issue fixture: {}", e))?;
// Extract the issue from the repo event (it's stored as the first 'e' tag)
let issue_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::e())
.and_then(|t| t.content())
.ok_or("Missing issue reference in RepoWithIssue fixture")?;
// Query to get the actual issue event
let filter = Filter::new().id(
nostr_sdk::EventId::from_hex(issue_id)
.map_err(|e| format!("Invalid issue ID: {}", e))?
);
let issues = client.query(filter).await
.map_err(|e| format!("Failed to query issue: {}", e))?;
let issue = issues.first()
.ok_or("Issue not found")?
.clone();
// Create comment using the helper (which adds NIP-22 tags including 'E')
let comment = Self::create_comment_for_event(client, &issue, "Comment content")?;
// Send comment and verify it's accepted (via E tag to accepted issue)
Self::send_and_verify_accepted(client, comment, "comment with E tag to accepted issue").await?;
Ok(())
@@ -693,6 +734,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 2.3: Kind 1 note with 'e' tag reply to accepted kind 1 should be accepted
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_kind1_via_e_tag(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_kind1_via_e_tag",
@@ -700,32 +745,34 @@ impl EventAcceptancePolicyTests {
"Accept kind 1 reply via 'e' tag to accepted kind 1",
)
.run(|| async {
// 1. Create and send repo
let repo = Self::create_test_repo(client, "repo-notes").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
// 2. Create Kind 1 A that quotes the repo (makes it accepted)
let repo_id = Self::extract_d_tag(&repo)
.ok_or("Failed to extract repo_id")?;
let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
let kind1_a = client
.event_builder(Kind::TextNote, "Note A about repo")
.tags(vec![Tag::custom(TagKind::custom("q"), vec![a_tag_value])])
.build(client.keys())
.map_err(|e| format!("Failed to build kind1 A: {}", e))?;
Self::send_and_verify_accepted(client, kind1_a.clone(), "kind 1 A quoting repo").await?;
// 3. Create Kind 1 B that replies to Kind 1 A via 'e' tag
let kind1_b = client
.event_builder(Kind::TextNote, "Reply to Note A")
.tags(vec![Tag::event(kind1_a.id)])
.build(client.keys())
.map_err(|e| format!("Failed to build kind1 B: {}", e))?;
// 4. Send Kind 1 B and verify it's accepted (via 'e' tag to accepted kind 1 A)
// Create TestContext
let ctx = TestContext::new(client);
// Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Create Kind 1 A that quotes the repo (makes it accepted)
let repo_id = Self::extract_d_tag(&repo)
.ok_or("Failed to extract repo_id")?;
let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id);
let kind1_a = client
.event_builder(Kind::TextNote, "Note A about repo")
.tags(vec![Tag::custom(TagKind::custom("q"), vec![a_tag_value])])
.build(client.keys())
.map_err(|e| format!("Failed to build kind1 A: {}", e))?;
Self::send_and_verify_accepted(client, kind1_a.clone(), "kind 1 A quoting repo").await?;
// Create Kind 1 B that replies to Kind 1 A via 'e' tag
let kind1_b = client
.event_builder(Kind::TextNote, "Reply to Note A")
.tags(vec![Tag::event(kind1_a.id)])
.build(client.keys())
.map_err(|e| format!("Failed to build kind1 B: {}", e))?;
// Send Kind 1 B and verify it's accepted (via 'e' tag to accepted kind 1 A)
Self::send_and_verify_accepted(client, kind1_b, "kind 1 B replying to accepted kind 1 A").await?;
Ok(())
@@ -738,6 +785,10 @@ impl EventAcceptancePolicyTests {
// ============================================================
/// Test 3.1: Kind 1 note should be accepted when referenced by an accepted issue (forward ref)
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_kind1_referenced_in_issue(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_kind1_referenced_in_issue",
@@ -745,37 +796,39 @@ impl EventAcceptancePolicyTests {
"Accept kind 1 referenced in accepted issue (forward ref)",
)
.run(|| async {
// 1. Create and send repo (this establishes the accepted context)
let repo = Self::create_test_repo(client, "repo-fwd-1").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
// 2. Create Kind 1 note locally but DON'T send it yet
let kind1_note = client
.event_builder(Kind::TextNote, "Note to be referenced")
.build(client.keys())
.map_err(|e| format!("Failed to build kind1: {}", e))?;
// 3. Create and send issue that QUOTES the unsent Kind 1 note
let issue_tags = vec![
// Reference to accepted repo
Tag::custom(TagKind::custom("a"), vec![
format!("30617:{}:{}", repo.pubkey, Self::extract_d_tag(&repo).unwrap())
]),
Tag::custom(TagKind::custom("subject"), vec!["Issue referencing kind1".to_string()]),
// Quote the Kind 1 that hasn't been sent yet
Tag::custom(TagKind::custom("q"), vec![kind1_note.id.to_hex()]),
];
let issue = client
.event_builder(Kind::Custom(1621), "issue content")
.tags(issue_tags)
.build(client.keys())
.map_err(|e| format!("Failed to build issue: {}", e))?;
Self::send_and_verify_accepted(client, issue, "issue quoting unsent kind1").await?;
// 4. NOW send the Kind 1 note - should be accepted because accepted issue quotes it
// Create TestContext
let ctx = TestContext::new(client);
// Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Create Kind 1 note locally but DON'T send it yet
let kind1_note = client
.event_builder(Kind::TextNote, "Note to be referenced")
.build(client.keys())
.map_err(|e| format!("Failed to build kind1: {}", e))?;
// Create and send issue that QUOTES the unsent Kind 1 note
let issue_tags = vec![
// Reference to accepted repo
Tag::custom(TagKind::custom("a"), vec![
format!("30617:{}:{}", repo.pubkey, Self::extract_d_tag(&repo).unwrap())
]),
Tag::custom(TagKind::custom("subject"), vec!["Issue referencing kind1".to_string()]),
// Quote the Kind 1 that hasn't been sent yet
Tag::custom(TagKind::custom("q"), vec![kind1_note.id.to_hex()]),
];
let issue = client
.event_builder(Kind::Custom(1621), "issue content")
.tags(issue_tags)
.build(client.keys())
.map_err(|e| format!("Failed to build issue: {}", e))?;
Self::send_and_verify_accepted(client, issue, "issue quoting unsent kind1").await?;
// NOW send the Kind 1 note - should be accepted because accepted issue quotes it
Self::send_and_verify_accepted(client, kind1_note, "kind1 note referenced by accepted issue").await?;
Ok(())
@@ -784,6 +837,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 3.2: Comment should be accepted when referenced by another accepted comment (forward ref)
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo+issue for full isolation
/// - In Production mode: Reuses cached repo+issue to minimize events
async fn test_accept_comment_referenced_in_comment(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_comment_referenced_in_comment",
@@ -791,38 +848,53 @@ impl EventAcceptancePolicyTests {
"Accept comment referenced in another accepted comment (forward ref)",
)
.run(|| async {
// 1. Create and send repo
let repo = Self::create_test_repo(client, "repo-fwd-2").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
// 2. Create and send issue (references repo, so it's accepted)
let issue = Self::create_issue_for_repo(client, &repo, "Issue for comments")?;
Self::send_and_verify_accepted(client, issue.clone(), "issue").await?;
// 3. Create Comment A locally but DON'T send it yet
let comment_a = Self::create_comment_for_event(client, &issue, "Comment A")?;
// 4. Create and send Comment B that quotes Comment A (which hasn't been sent)
let comment_b_tags = vec![
// NIP-22 tags for the original issue
Tag::custom(TagKind::custom("E"), vec![issue.id.to_hex(), "".to_string(), "root".to_string()]),
Tag::event(issue.id),
Tag::custom(TagKind::custom("K"), vec![issue.kind.as_u16().to_string()]),
Tag::public_key(issue.pubkey),
// Quote Comment A which hasn't been sent yet
Tag::custom(TagKind::custom("q"), vec![comment_a.id.to_hex()]),
];
let comment_b = client
.event_builder(Kind::Custom(1111), "Comment B quoting Comment A")
.tags(comment_b_tags)
.build(client.keys())
.map_err(|e| format!("Failed to build comment B: {}", e))?;
Self::send_and_verify_accepted(client, comment_b, "comment B quoting unsent comment A").await?;
// 5. NOW send Comment A - should be accepted because accepted Comment B quotes it
// Create TestContext
let ctx = TestContext::new(client);
// Get repo with issue fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::RepoWithIssue).await
.map_err(|e| format!("Test setup failed: could not get repo with issue fixture: {}", e))?;
// Extract the issue from the repo event (it's stored as the first 'e' tag)
let issue_id = repo.tags.iter()
.find(|t| t.kind() == TagKind::e())
.and_then(|t| t.content())
.ok_or("Missing issue reference in RepoWithIssue fixture")?;
// Query to get the actual issue event
let filter = Filter::new().id(
nostr_sdk::EventId::from_hex(issue_id)
.map_err(|e| format!("Invalid issue ID: {}", e))?
);
let issues = client.query(filter).await
.map_err(|e| format!("Failed to query issue: {}", e))?;
let issue = issues.first()
.ok_or("Issue not found")?
.clone();
// Create Comment A locally but DON'T send it yet
let comment_a = Self::create_comment_for_event(client, &issue, "Comment A")?;
// Create and send Comment B that quotes Comment A (which hasn't been sent)
let comment_b_tags = vec![
// NIP-22 tags for the original issue
Tag::custom(TagKind::custom("E"), vec![issue.id.to_hex(), "".to_string(), "root".to_string()]),
Tag::event(issue.id),
Tag::custom(TagKind::custom("K"), vec![issue.kind.as_u16().to_string()]),
Tag::public_key(issue.pubkey),
// Quote Comment A which hasn't been sent yet
Tag::custom(TagKind::custom("q"), vec![comment_a.id.to_hex()]),
];
let comment_b = client
.event_builder(Kind::Custom(1111), "Comment B quoting Comment A")
.tags(comment_b_tags)
.build(client.keys())
.map_err(|e| format!("Failed to build comment B: {}", e))?;
Self::send_and_verify_accepted(client, comment_b, "comment B quoting unsent comment A").await?;
// NOW send Comment A - should be accepted because accepted Comment B quotes it
Self::send_and_verify_accepted(client, comment_a, "comment A referenced by accepted comment B").await?;
Ok(())
@@ -831,6 +903,10 @@ impl EventAcceptancePolicyTests {
}
/// Test 3.3: Kind 1 note should be accepted when referenced by another accepted kind 1 (forward ref)
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh repo for full isolation
/// - In Production mode: Reuses cached repo to minimize events
async fn test_accept_kind1_referenced_in_kind1(client: &AuditClient) -> TestResult {
TestResult::new(
"accept_kind1_referenced_in_kind1",
@@ -838,17 +914,20 @@ impl EventAcceptancePolicyTests {
"Accept kind 1 referenced in another accepted kind 1 (forward ref)",
)
.run(|| async {
// 1. Create and send repo
let repo = Self::create_test_repo(client, "repo-fwd-3").await?;
Self::send_and_verify_accepted(client, repo.clone(), "repo").await?;
// Create TestContext
let ctx = TestContext::new(client);
// 2. Create Kind 1 A locally but DON'T send it yet
// Get repository fixture (mode-aware)
let repo = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Create Kind 1 A locally but DON'T send it yet
let kind1_a = client
.event_builder(Kind::TextNote, "Note A to be referenced")
.build(client.keys())
.map_err(|e| format!("Failed to build kind1 A: {}", e))?;
// 3. Create and send Kind 1 B that:
// Create and send Kind 1 B that:
// - Quotes the repo (makes it accepted)
// - Mentions Kind 1 A via 'e' tag (which hasn't been sent yet)
let repo_id = Self::extract_d_tag(&repo)
@@ -866,7 +945,7 @@ impl EventAcceptancePolicyTests {
Self::send_and_verify_accepted(client, kind1_b, "kind1 B mentioning unsent kind1 A").await?;
// 4. NOW send Kind 1 A - should be accepted because accepted Kind 1 B mentions it
// NOW send Kind 1 A - should be accepted because accepted Kind 1 B mentions it
Self::send_and_verify_accepted(client, kind1_a, "kind1 A referenced by accepted kind1 B").await?;
Ok(())
@@ -923,6 +1002,11 @@ impl EventAcceptancePolicyTests {
}
/// Test 4.3: Comment quoting unaccepted repo should be rejected
///
/// **Using TestContext pattern:**
/// - In CI mode: Creates fresh accepted repo for full isolation
/// - In Production mode: Reuses cached accepted repo to minimize events
/// - Note: Unaccepted repo B is always created fresh (not cached) since it must remain unaccepted
async fn test_reject_comment_quoting_other_repo(client: &AuditClient) -> TestResult {
TestResult::new(
"reject_comment_quoting_other_repo",
@@ -930,19 +1014,22 @@ impl EventAcceptancePolicyTests {
"Reject comment quoting unaccepted repo",
)
.run(|| async {
// 1. Create and send Repo A (this one IS accepted)
let repo_a = Self::create_test_repo(client, "accepted-repo-a").await?;
Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?;
// Create TestContext
let ctx = TestContext::new(client);
// 2. Create Repo B but DON'T send it (unaccepted)
// Get accepted repo A fixture (mode-aware)
let _repo_a = ctx.get_fixture(FixtureKind::ValidRepo).await
.map_err(|e| format!("Test setup failed: could not get valid repository fixture: {}", e))?;
// Create Repo B but DON'T send it (unaccepted)
let repo_b = Self::create_test_repo(client, "unaccepted-repo-b").await?;
// 3. Extract repo_b info and create comment that quotes repo B (not repo A)
// Extract repo_b info and create comment that quotes repo B (not repo A)
let repo_b_id = Self::extract_d_tag(&repo_b)
.ok_or("Failed to extract repo_b id")?;
let repo_b_a_tag = format!("30617:{}:{}", repo_b.pubkey, repo_b_id);
// 4. Create comment that references ONLY repo B (unaccepted)
// Create comment that references ONLY repo B (unaccepted)
let tags = vec![
Tag::custom(TagKind::custom("A"), vec![repo_b_a_tag, "".to_string(), "root".to_string()]),
Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]),
@@ -955,7 +1042,7 @@ impl EventAcceptancePolicyTests {
.build(client.keys())
.map_err(|e| format!("Failed to build comment: {}", e))?;
// 5. Send comment and verify it's REJECTED (only references unaccepted repo B)
// Send comment and verify it's REJECTED (only references unaccepted repo B)
Self::send_and_verify_rejected(client, comment, "comment quoting only unaccepted repo").await?;
Ok(())