diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-06 12:59:29 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-06 15:17:32 +0000 |
| commit | 16d14d07b023614c1da0fbb11693d131327a3532 (patch) | |
| tree | c6e4b2fe6bae57bd35e8d526f281a84b515641f2 /grasp-audit/src/fixtures.rs | |
| parent | ad6b8a825a500896d613fed72c11e7cbce3ddfd9 (diff) | |
fix cli runs to prevent rate limiting
Diffstat (limited to 'grasp-audit/src/fixtures.rs')
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 312 |
1 files changed, 312 insertions, 0 deletions
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 @@ | |||
| 1 | //! Test fixture management for dual-mode testing | ||
| 2 | //! | ||
| 3 | //! This module provides a TestContext abstraction that manages prerequisite events | ||
| 4 | //! differently based on the audit mode: | ||
| 5 | //! | ||
| 6 | //! - **CI Mode (Isolated)**: Creates fresh events for each test, ensuring complete isolation | ||
| 7 | //! - **Production Mode (Shared)**: Reuses shared fixtures to minimize event publication | ||
| 8 | //! | ||
| 9 | //! # Example | ||
| 10 | //! | ||
| 11 | //! ```no_run | ||
| 12 | //! use grasp_audit::*; | ||
| 13 | //! | ||
| 14 | //! # async fn example() -> anyhow::Result<()> { | ||
| 15 | //! let config = AuditConfig::ci(); | ||
| 16 | //! let client = AuditClient::new("ws://localhost:7000", config).await?; | ||
| 17 | //! let ctx = TestContext::new(&client); | ||
| 18 | //! | ||
| 19 | //! // Request a fixture - behavior depends on mode | ||
| 20 | //! let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; | ||
| 21 | //! # Ok(()) | ||
| 22 | //! # } | ||
| 23 | //! ``` | ||
| 24 | |||
| 25 | use crate::{AuditClient, AuditMode}; | ||
| 26 | use anyhow::{Context, Result}; | ||
| 27 | use nostr_sdk::prelude::Event; | ||
| 28 | use std::collections::HashMap; | ||
| 29 | use std::sync::{Arc, Mutex}; | ||
| 30 | |||
| 31 | /// Types of test fixtures available | ||
| 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| 33 | pub enum FixtureKind { | ||
| 34 | /// Basic repository announcement (kind 30617) | ||
| 35 | ValidRepo, | ||
| 36 | |||
| 37 | /// Repository with one issue (kind 1621) | ||
| 38 | RepoWithIssue, | ||
| 39 | |||
| 40 | /// Repository with issue and comment (kind 1111) | ||
| 41 | RepoWithComment, | ||
| 42 | |||
| 43 | /// Repository state announcement (kind 30618) | ||
| 44 | RepoState, | ||
| 45 | } | ||
| 46 | |||
| 47 | /// Context mode for fixture management | ||
| 48 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 49 | pub enum ContextMode { | ||
| 50 | /// Create fresh fixtures for each request (test isolation) | ||
| 51 | Isolated, | ||
| 52 | |||
| 53 | /// Reuse shared fixtures across requests (minimal events) | ||
| 54 | Shared, | ||
| 55 | } | ||
| 56 | |||
| 57 | impl From<AuditMode> for ContextMode { | ||
| 58 | fn from(mode: AuditMode) -> Self { | ||
| 59 | match mode { | ||
| 60 | AuditMode::CI => ContextMode::Isolated, | ||
| 61 | AuditMode::Production => ContextMode::Shared, | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | /// Test context for managing prerequisite events | ||
| 67 | /// | ||
| 68 | /// The TestContext provides mode-aware fixture management: | ||
| 69 | /// - In Isolated mode: Creates fresh events for each test | ||
| 70 | /// - In Shared mode: Caches and reuses events across tests | ||
| 71 | /// | ||
| 72 | /// # Example | ||
| 73 | /// | ||
| 74 | /// ```no_run | ||
| 75 | /// # use grasp_audit::*; | ||
| 76 | /// # async fn example() -> anyhow::Result<()> { | ||
| 77 | /// let config = AuditConfig::ci(); | ||
| 78 | /// let client = AuditClient::new("ws://localhost:7000", config).await?; | ||
| 79 | /// let ctx = TestContext::new(&client); | ||
| 80 | /// | ||
| 81 | /// // Get a repository fixture | ||
| 82 | /// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; | ||
| 83 | /// | ||
| 84 | /// // In CI mode: Creates new repo | ||
| 85 | /// // In Production mode: Returns cached repo | ||
| 86 | /// # Ok(()) | ||
| 87 | /// # } | ||
| 88 | /// ``` | ||
| 89 | pub struct TestContext<'a> { | ||
| 90 | client: &'a AuditClient, | ||
| 91 | mode: ContextMode, | ||
| 92 | cache: Arc<Mutex<HashMap<FixtureKind, Event>>>, | ||
| 93 | } | ||
| 94 | |||
| 95 | impl<'a> TestContext<'a> { | ||
| 96 | /// Create a new test context | ||
| 97 | /// | ||
| 98 | /// The context mode is automatically determined from the client's audit config. | ||
| 99 | pub fn new(client: &'a AuditClient) -> Self { | ||
| 100 | let mode = ContextMode::from(client.config.mode); | ||
| 101 | Self { | ||
| 102 | client, | ||
| 103 | mode, | ||
| 104 | cache: Arc::new(Mutex::new(HashMap::new())), | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | /// Create a test context with explicit mode override | ||
| 109 | /// | ||
| 110 | /// This is useful for testing the context itself or for advanced use cases | ||
| 111 | /// where you want to override the default mode behavior. | ||
| 112 | pub fn with_mode(client: &'a AuditClient, mode: ContextMode) -> Self { | ||
| 113 | Self { | ||
| 114 | client, | ||
| 115 | mode, | ||
| 116 | cache: Arc::new(Mutex::new(HashMap::new())), | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | /// Get a fixture, creating it if needed based on mode | ||
| 121 | /// | ||
| 122 | /// # Behavior | ||
| 123 | /// | ||
| 124 | /// - **Isolated mode**: Always creates a fresh fixture | ||
| 125 | /// - **Shared mode**: Returns cached fixture or creates and caches if not present | ||
| 126 | /// | ||
| 127 | /// # Example | ||
| 128 | /// | ||
| 129 | /// ```no_run | ||
| 130 | /// # use grasp_audit::*; | ||
| 131 | /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> { | ||
| 132 | /// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?; | ||
| 133 | /// # Ok(()) | ||
| 134 | /// # } | ||
| 135 | /// ``` | ||
| 136 | pub async fn get_fixture(&self, kind: FixtureKind) -> Result<Event> { | ||
| 137 | match self.mode { | ||
| 138 | ContextMode::Isolated => self.create_fresh(kind).await, | ||
| 139 | ContextMode::Shared => self.get_or_create_shared(kind).await, | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | /// Get the underlying client for direct access | ||
| 144 | /// | ||
| 145 | /// This allows tests to use the client directly when needed while still | ||
| 146 | /// benefiting from the TestContext for fixture management. | ||
| 147 | pub fn client(&self) -> &'a AuditClient { | ||
| 148 | self.client | ||
| 149 | } | ||
| 150 | |||
| 151 | /// Get the current context mode | ||
| 152 | pub fn mode(&self) -> ContextMode { | ||
| 153 | self.mode | ||
| 154 | } | ||
| 155 | |||
| 156 | /// Create a fresh fixture (always creates new) | ||
| 157 | async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> { | ||
| 158 | let event = self.build_fixture(kind).await | ||
| 159 | .with_context(|| format!("Failed to build {:?} fixture", kind))?; | ||
| 160 | |||
| 161 | self.client.send_event(event.clone()).await | ||
| 162 | .with_context(|| format!("Failed to send {:?} fixture event to relay", kind))?; | ||
| 163 | |||
| 164 | Ok(event) | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Get or create a shared fixture (caches for reuse) | ||
| 168 | async fn get_or_create_shared(&self, kind: FixtureKind) -> Result<Event> { | ||
| 169 | // Check cache first | ||
| 170 | { | ||
| 171 | let cache = self.cache.lock().unwrap(); | ||
| 172 | if let Some(event) = cache.get(&kind) { | ||
| 173 | return Ok(event.clone()); | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | // Not in cache, create it | ||
| 178 | let event = self.build_fixture(kind).await | ||
| 179 | .with_context(|| format!("Failed to build {:?} fixture for shared cache", kind))?; | ||
| 180 | |||
| 181 | self.client.send_event(event.clone()).await | ||
| 182 | .with_context(|| format!("Failed to send {:?} fixture event to relay (shared cache)", kind))?; | ||
| 183 | |||
| 184 | // Store in cache | ||
| 185 | { | ||
| 186 | let mut cache = self.cache.lock().unwrap(); | ||
| 187 | cache.insert(kind, event.clone()); | ||
| 188 | } | ||
| 189 | |||
| 190 | Ok(event) | ||
| 191 | } | ||
| 192 | |||
| 193 | /// Build a fixture event (doesn't send it) | ||
| 194 | async fn build_fixture(&self, kind: FixtureKind) -> Result<Event> { | ||
| 195 | match kind { | ||
| 196 | FixtureKind::ValidRepo => { | ||
| 197 | let test_name = format!("fixture-{:?}-{}", kind, &uuid::Uuid::new_v4().to_string()[..8]); | ||
| 198 | self.client.create_repo_announcement(&test_name).await | ||
| 199 | } | ||
| 200 | |||
| 201 | FixtureKind::RepoWithIssue => { | ||
| 202 | // First create repo | ||
| 203 | let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]); | ||
| 204 | let repo = self.client.create_repo_announcement(&test_name).await?; | ||
| 205 | self.client.send_event(repo.clone()).await?; | ||
| 206 | |||
| 207 | // Then create issue referencing it | ||
| 208 | self.client.create_issue( | ||
| 209 | &repo, | ||
| 210 | "Test Issue", | ||
| 211 | "Issue content for testing", | ||
| 212 | vec![], | ||
| 213 | ) | ||
| 214 | } | ||
| 215 | |||
| 216 | FixtureKind::RepoWithComment => { | ||
| 217 | // First create repo with issue | ||
| 218 | let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]); | ||
| 219 | let repo = self.client.create_repo_announcement(&test_name).await?; | ||
| 220 | self.client.send_event(repo.clone()).await?; | ||
| 221 | |||
| 222 | let issue = self.client.create_issue( | ||
| 223 | &repo, | ||
| 224 | "Test Issue", | ||
| 225 | "Issue content", | ||
| 226 | vec![], | ||
| 227 | )?; | ||
| 228 | self.client.send_event(issue.clone()).await?; | ||
| 229 | |||
| 230 | // Then create comment on issue | ||
| 231 | self.client.create_comment( | ||
| 232 | &issue, | ||
| 233 | "Test comment", | ||
| 234 | vec![], | ||
| 235 | ) | ||
| 236 | } | ||
| 237 | |||
| 238 | FixtureKind::RepoState => { | ||
| 239 | use nostr_sdk::prelude::*; | ||
| 240 | |||
| 241 | // First create repo announcement | ||
| 242 | let test_name = format!("fixture-{:?}-{}", FixtureKind::ValidRepo, &uuid::Uuid::new_v4().to_string()[..8]); | ||
| 243 | let repo = self.client.create_repo_announcement(&test_name).await?; | ||
| 244 | self.client.send_event(repo.clone()).await?; | ||
| 245 | |||
| 246 | // Extract repo_id from repo announcement | ||
| 247 | let repo_id = repo.tags.iter() | ||
| 248 | .find(|t| t.kind() == TagKind::d()) | ||
| 249 | .and_then(|t| t.content()) | ||
| 250 | .ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))? | ||
| 251 | .to_string(); | ||
| 252 | |||
| 253 | // Create state announcement | ||
| 254 | self.client.event_builder(Kind::Custom(30618), "") | ||
| 255 | .tag(Tag::identifier(&repo_id)) | ||
| 256 | .tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![ | ||
| 257 | "abc123def456789012345678901234567890abcd" | ||
| 258 | ])) | ||
| 259 | .tag(Tag::custom(TagKind::custom("HEAD"), vec![ | ||
| 260 | "ref: refs/heads/main" | ||
| 261 | ])) | ||
| 262 | .build(self.client.keys()) | ||
| 263 | .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) | ||
| 264 | } | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Clear the fixture cache | ||
| 269 | /// | ||
| 270 | /// This is useful for tests that want to ensure fresh fixtures | ||
| 271 | /// even in shared mode. | ||
| 272 | pub fn clear_cache(&self) { | ||
| 273 | let mut cache = self.cache.lock().unwrap(); | ||
| 274 | cache.clear(); | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | #[cfg(test)] | ||
| 279 | mod tests { | ||
| 280 | use super::*; | ||
| 281 | use crate::AuditConfig; | ||
| 282 | |||
| 283 | #[test] | ||
| 284 | fn test_context_mode_from_audit_mode() { | ||
| 285 | assert_eq!(ContextMode::from(AuditMode::CI), ContextMode::Isolated); | ||
| 286 | assert_eq!(ContextMode::from(AuditMode::Production), ContextMode::Shared); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn test_fixture_kind_hash() { | ||
| 291 | use std::collections::HashSet; | ||
| 292 | |||
| 293 | let mut set = HashSet::new(); | ||
| 294 | set.insert(FixtureKind::ValidRepo); | ||
| 295 | set.insert(FixtureKind::RepoWithIssue); | ||
| 296 | |||
| 297 | assert!(set.contains(&FixtureKind::ValidRepo)); | ||
| 298 | assert!(!set.contains(&FixtureKind::RepoWithComment)); | ||
| 299 | } | ||
| 300 | |||
| 301 | #[tokio::test] | ||
| 302 | async fn test_context_creation() { | ||
| 303 | let config = AuditConfig::ci(); | ||
| 304 | let client = crate::AuditClient::new_test(config); | ||
| 305 | |||
| 306 | let ctx = TestContext::new(&client); | ||
| 307 | assert_eq!(ctx.mode(), ContextMode::Isolated); | ||
| 308 | |||
| 309 | let ctx = TestContext::with_mode(&client, ContextMode::Shared); | ||
| 310 | assert_eq!(ctx.mode(), ContextMode::Shared); | ||
| 311 | } | ||
| 312 | } \ No newline at end of file | ||