From 16d14d07b023614c1da0fbb11693d131327a3532 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 6 Nov 2025 12:59:29 +0000 Subject: fix cli runs to prevent rate limiting --- grasp-audit/src/audit.rs | 2 +- grasp-audit/src/client.rs | 14 +- grasp-audit/src/fixtures.rs | 312 +++++++++++++ grasp-audit/src/isolation.rs | 2 +- grasp-audit/src/lib.rs | 2 + .../src/specs/grasp01/event_acceptance_policy.rs | 495 ++++++++++++--------- 6 files changed, 620 insertions(+), 207 deletions(-) create mode 100644 grasp-audit/src/fixtures.rs (limited to 'grasp-audit/src') diff --git a/grasp-audit/src/audit.rs b/grasp-audit/src/audit.rs index 105fa00..8afe660 100644 --- a/grasp-audit/src/audit.rs +++ b/grasp-audit/src/audit.rs @@ -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, diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index 74a16d8..1f6f0fb 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -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 { 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() diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs new file mode 100644 index 0000000..e34ee6d --- /dev/null +++ b/grasp-audit/src/fixtures.rs @@ -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 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>>, +} + +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 { + 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 { + 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 { + // 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 { + 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); + } +} \ No newline at end of file diff --git a/grasp-audit/src/isolation.rs b/grasp-audit/src/isolation.rs index 298781a..540da34 100644 --- a/grasp-audit/src/isolation.rs +++ b/grasp-audit/src/isolation.rs @@ -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 diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index 3a6404f..6eac73c 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -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}; diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs index d235eb0..353d2c3 100644 --- a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs +++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs @@ -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", ""] - 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 { 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 { 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 { 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); + + // 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 (uses create_issue_for_repo helper) + // 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); + + // 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. Extract repo_id and create `q` tag + // 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); + + // 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))?; - // 2. Create Repo B but DON'T send it (unaccepted) + // 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(()) -- cgit v1.2.3