diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-28 04:27:07 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-28 04:27:07 +0000 |
| commit | eee0a521afe0492f04bee58c9a236683fb23601b (patch) | |
| tree | b0414e164de2946e39613a45f5a2724eadfe1821 /grasp-audit/src/fixtures.rs | |
| parent | b2a43ffbd965a2b561d7b4e4c582dfeadd099ed3 (diff) | |
audit: fix shared test context to minimise events sent to production relays
Diffstat (limited to 'grasp-audit/src/fixtures.rs')
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 109 |
1 files changed, 72 insertions, 37 deletions
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 89531c6..81620f6 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs | |||
| @@ -6,6 +6,17 @@ | |||
| 6 | //! - **CI Mode (Isolated)**: Creates fresh events for each test, ensuring complete isolation | 6 | //! - **CI Mode (Isolated)**: Creates fresh events for each test, ensuring complete isolation |
| 7 | //! - **Production Mode (Shared)**: Reuses shared fixtures to minimize event publication | 7 | //! - **Production Mode (Shared)**: Reuses shared fixtures to minimize event publication |
| 8 | //! | 8 | //! |
| 9 | //! # Cache Sharing Strategy | ||
| 10 | //! | ||
| 11 | //! The fixture cache lives on the `AuditClient`, not on `TestContext`. This provides | ||
| 12 | //! natural cache sharing semantics: | ||
| 13 | //! | ||
| 14 | //! - **CLI mode**: Creates one `AuditClient` → fixtures shared across all tests | ||
| 15 | //! - **cargo test**: Creates one `AuditClient` per test → fixtures isolated per test | ||
| 16 | //! | ||
| 17 | //! This eliminates the need for global state while still enabling fixture reuse | ||
| 18 | //! when appropriate. | ||
| 19 | //! | ||
| 9 | //! # Example | 20 | //! # Example |
| 10 | //! | 21 | //! |
| 11 | //! ```no_run | 22 | //! ```no_run |
| @@ -25,8 +36,6 @@ | |||
| 25 | use crate::{AuditClient, AuditMode}; | 36 | use crate::{AuditClient, AuditMode}; |
| 26 | use anyhow::{Context, Result}; | 37 | use anyhow::{Context, Result}; |
| 27 | use nostr_sdk::prelude::Event; | 38 | use nostr_sdk::prelude::Event; |
| 28 | use std::collections::HashMap; | ||
| 29 | use std::sync::{Arc, Mutex}; | ||
| 30 | 39 | ||
| 31 | /// Deterministic commit hash used in RepoState fixtures (Owner variant) | 40 | /// Deterministic commit hash used in RepoState fixtures (Owner variant) |
| 32 | /// This is the hash produced by creating a commit with: | 41 | /// This is the hash produced by creating a commit with: |
| @@ -161,6 +170,13 @@ impl From<AuditMode> for ContextMode { | |||
| 161 | /// - In Isolated mode: Creates fresh events for each test | 170 | /// - In Isolated mode: Creates fresh events for each test |
| 162 | /// - In Shared mode: Caches and reuses events across tests | 171 | /// - In Shared mode: Caches and reuses events across tests |
| 163 | /// | 172 | /// |
| 173 | /// # Cache Location | ||
| 174 | /// | ||
| 175 | /// The fixture cache lives on `AuditClient`, not on `TestContext`. This means: | ||
| 176 | /// - Multiple `TestContext` instances from the same client share the cache | ||
| 177 | /// - CLI mode (one client) naturally shares fixtures across all tests | ||
| 178 | /// - Test mode (one client per test) naturally isolates fixtures | ||
| 179 | /// | ||
| 164 | /// # Example | 180 | /// # Example |
| 165 | /// | 181 | /// |
| 166 | /// ```no_run | 182 | /// ```no_run |
| @@ -181,20 +197,18 @@ impl From<AuditMode> for ContextMode { | |||
| 181 | pub struct TestContext<'a> { | 197 | pub struct TestContext<'a> { |
| 182 | client: &'a AuditClient, | 198 | client: &'a AuditClient, |
| 183 | mode: ContextMode, | 199 | mode: ContextMode, |
| 184 | cache: Arc<Mutex<HashMap<FixtureKind, Event>>>, | ||
| 185 | } | 200 | } |
| 186 | 201 | ||
| 187 | impl<'a> TestContext<'a> { | 202 | impl<'a> TestContext<'a> { |
| 188 | /// Create a new test context | 203 | /// Create a new test context |
| 189 | /// | 204 | /// |
| 190 | /// The context mode is automatically determined from the client's audit config. | 205 | /// The context mode is automatically determined from the client's audit config. |
| 206 | /// The fixture cache is borrowed from the client, enabling natural sharing: | ||
| 207 | /// - Same client = shared cache (CLI mode behavior) | ||
| 208 | /// - Different clients = isolated caches (test mode behavior) | ||
| 191 | pub fn new(client: &'a AuditClient) -> Self { | 209 | pub fn new(client: &'a AuditClient) -> Self { |
| 192 | let mode = ContextMode::from(client.config.mode); | 210 | let mode = ContextMode::from(client.config.mode); |
| 193 | Self { | 211 | Self { client, mode } |
| 194 | client, | ||
| 195 | mode, | ||
| 196 | cache: Arc::new(Mutex::new(HashMap::new())), | ||
| 197 | } | ||
| 198 | } | 212 | } |
| 199 | 213 | ||
| 200 | /// Create a test context with explicit mode override | 214 | /// Create a test context with explicit mode override |
| @@ -202,11 +216,7 @@ impl<'a> TestContext<'a> { | |||
| 202 | /// This is useful for testing the context itself or for advanced use cases | 216 | /// This is useful for testing the context itself or for advanced use cases |
| 203 | /// where you want to override the default mode behavior. | 217 | /// where you want to override the default mode behavior. |
| 204 | pub fn with_mode(client: &'a AuditClient, mode: ContextMode) -> Self { | 218 | pub fn with_mode(client: &'a AuditClient, mode: ContextMode) -> Self { |
| 205 | Self { | 219 | Self { client, mode } |
| 206 | client, | ||
| 207 | mode, | ||
| 208 | cache: Arc::new(Mutex::new(HashMap::new())), | ||
| 209 | } | ||
| 210 | } | 220 | } |
| 211 | 221 | ||
| 212 | /// Get a fixture, creating it if needed based on mode | 222 | /// Get a fixture, creating it if needed based on mode |
| @@ -261,15 +271,28 @@ impl<'a> TestContext<'a> { | |||
| 261 | } | 271 | } |
| 262 | 272 | ||
| 263 | /// Get or create a shared fixture (caches for reuse) | 273 | /// Get or create a shared fixture (caches for reuse) |
| 274 | /// | ||
| 275 | /// Uses the client's fixture cache to ensure fixtures are reused across | ||
| 276 | /// all TestContext instances in Production mode. | ||
| 264 | async fn get_or_create_shared(&self, kind: FixtureKind) -> Result<Event> { | 277 | async fn get_or_create_shared(&self, kind: FixtureKind) -> Result<Event> { |
| 265 | // Check cache first | 278 | // Check client's cache first (shared across all TestContext instances using same client) |
| 266 | { | 279 | { |
| 267 | let cache = self.cache.lock().unwrap(); | 280 | let cache = self.client.fixture_cache().lock().unwrap(); |
| 268 | if let Some(event) = cache.get(&kind) { | 281 | if let Some(event) = cache.get(&kind) { |
| 282 | tracing::debug!("get_or_create_shared({:?}) found in client cache", kind); | ||
| 269 | return Ok(event.clone()); | 283 | return Ok(event.clone()); |
| 270 | } | 284 | } |
| 271 | } | 285 | } |
| 272 | 286 | ||
| 287 | // Check relay connection before attempting to build | ||
| 288 | let is_connected = self.client.is_connected().await; | ||
| 289 | if !is_connected { | ||
| 290 | return Err(anyhow::anyhow!( | ||
| 291 | "Relay connection lost before building {:?} fixture (shared cache mode)", | ||
| 292 | kind | ||
| 293 | )); | ||
| 294 | } | ||
| 295 | |||
| 273 | // Not in cache, create it | 296 | // Not in cache, create it |
| 274 | let event = self | 297 | let event = self |
| 275 | .build_fixture(kind) | 298 | .build_fixture(kind) |
| @@ -286,64 +309,76 @@ impl<'a> TestContext<'a> { | |||
| 286 | ) | 309 | ) |
| 287 | })?; | 310 | })?; |
| 288 | 311 | ||
| 289 | // Store in cache | 312 | // Store in client's cache (shared across all TestContext instances using same client) |
| 290 | { | 313 | { |
| 291 | let mut cache = self.cache.lock().unwrap(); | 314 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 292 | cache.insert(kind, event.clone()); | 315 | cache.insert(kind, event.clone()); |
| 316 | tracing::debug!("get_or_create_shared({:?}) stored in client cache ({} entries)", kind, cache.len()); | ||
| 293 | } | 317 | } |
| 294 | 318 | ||
| 295 | Ok(event) | 319 | Ok(event) |
| 296 | } | 320 | } |
| 297 | 321 | ||
| 298 | /// Get or create a ValidRepo, with caching within the TestContext. | 322 | /// Get or create a ValidRepo, with caching. |
| 299 | /// This is a helper method that avoids async recursion by not going | 323 | /// This is a helper method that avoids async recursion by not going |
| 300 | /// through get_fixture. It handles the repo specifically. | 324 | /// through get_fixture. It handles the repo specifically. |
| 301 | /// | 325 | /// |
| 302 | /// IMPORTANT: We always cache within a TestContext instance to ensure | 326 | /// Uses client's fixture_cache for caching - in Shared mode this enables |
| 303 | /// fixture dependencies work correctly. The isolation between tests | 327 | /// cross-test reuse when the same client is used. |
| 304 | /// comes from each test having its own TestContext with a fresh cache. | ||
| 305 | async fn get_or_create_repo(&self) -> Result<Event> { | 328 | async fn get_or_create_repo(&self) -> Result<Event> { |
| 306 | // Always check cache first - this ensures fixture dependencies work | 329 | // Check client's cache first (shared across all TestContext instances using same client) |
| 307 | // (e.g., MaintainerRepoAndState needs the SAME repo_id as RepoState) | ||
| 308 | { | 330 | { |
| 309 | let cache = self.cache.lock().unwrap(); | 331 | let cache = self.client.fixture_cache().lock().unwrap(); |
| 310 | if let Some(event) = cache.get(&FixtureKind::ValidRepo) { | 332 | if let Some(event) = cache.get(&FixtureKind::ValidRepo) { |
| 333 | tracing::debug!("get_or_create_repo() found in client cache"); | ||
| 311 | return Ok(event.clone()); | 334 | return Ok(event.clone()); |
| 312 | } | 335 | } |
| 313 | } | 336 | } |
| 314 | 337 | ||
| 338 | // Check relay connection before creating repo | ||
| 339 | let is_connected = self.client.is_connected().await; | ||
| 340 | if !is_connected { | ||
| 341 | return Err(anyhow::anyhow!( | ||
| 342 | "Relay connection lost before creating ValidRepo fixture" | ||
| 343 | )); | ||
| 344 | } | ||
| 345 | |||
| 315 | // Create a new repo | 346 | // Create a new repo |
| 316 | let test_name = format!( | 347 | let test_name = format!( |
| 317 | "fixture-{:?}-{}", | 348 | "fixture-{:?}-{}", |
| 318 | FixtureKind::ValidRepo, | 349 | FixtureKind::ValidRepo, |
| 319 | &uuid::Uuid::new_v4().to_string()[..8] | 350 | &uuid::Uuid::new_v4().to_string()[..8] |
| 320 | ); | 351 | ); |
| 321 | let repo = self.client.create_repo_announcement(&test_name).await?; | 352 | |
| 353 | let repo = self.client.create_repo_announcement(&test_name).await | ||
| 354 | .with_context(|| format!("create_repo_announcement failed for {}", test_name))?; | ||
| 322 | 355 | ||
| 323 | // Send it | 356 | // Send it |
| 324 | self.client.send_event(repo.clone()).await?; | 357 | self.client.send_event(repo.clone()).await |
| 358 | .with_context(|| "Failed to send repo announcement to relay")?; | ||
| 325 | 359 | ||
| 326 | // Always cache it - isolation comes from each test having its own TestContext | 360 | // Store in client's cache (shared across all TestContext instances using same client) |
| 327 | { | 361 | { |
| 328 | let mut cache = self.cache.lock().unwrap(); | 362 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 329 | cache.insert(FixtureKind::ValidRepo, repo.clone()); | 363 | cache.insert(FixtureKind::ValidRepo, repo.clone()); |
| 364 | tracing::debug!("get_or_create_repo() stored in client cache ({} entries)", cache.len()); | ||
| 330 | } | 365 | } |
| 331 | 366 | ||
| 332 | Ok(repo) | 367 | Ok(repo) |
| 333 | } | 368 | } |
| 334 | 369 | ||
| 335 | /// Get or create a RepoWithIssue, with caching within the TestContext. | 370 | /// Get or create a RepoWithIssue, with caching via the client. |
| 336 | /// Returns the issue event (repo is already sent/cached via get_or_create_repo). | 371 | /// Returns the issue event (repo is already sent/cached via get_or_create_repo). |
| 337 | async fn get_or_create_issue(&self) -> Result<Event> { | 372 | async fn get_or_create_issue(&self) -> Result<Event> { |
| 338 | // Always check cache first - ensures fixture dependencies work | 373 | // Check client's cache first |
| 339 | { | 374 | { |
| 340 | let cache = self.cache.lock().unwrap(); | 375 | let cache = self.client.fixture_cache().lock().unwrap(); |
| 341 | if let Some(event) = cache.get(&FixtureKind::RepoWithIssue) { | 376 | if let Some(event) = cache.get(&FixtureKind::RepoWithIssue) { |
| 342 | return Ok(event.clone()); | 377 | return Ok(event.clone()); |
| 343 | } | 378 | } |
| 344 | } | 379 | } |
| 345 | 380 | ||
| 346 | // Get or create repo (reuses cached within this TestContext) | 381 | // Get or create repo (reuses cached via client) |
| 347 | let repo = self.get_or_create_repo().await?; | 382 | let repo = self.get_or_create_repo().await?; |
| 348 | 383 | ||
| 349 | // Create the issue | 384 | // Create the issue |
| @@ -357,9 +392,9 @@ impl<'a> TestContext<'a> { | |||
| 357 | // Send it | 392 | // Send it |
| 358 | self.client.send_event(issue.clone()).await?; | 393 | self.client.send_event(issue.clone()).await?; |
| 359 | 394 | ||
| 360 | // Always cache it - isolation comes from each test having its own TestContext | 395 | // Store in client's cache |
| 361 | { | 396 | { |
| 362 | let mut cache = self.cache.lock().unwrap(); | 397 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 363 | cache.insert(FixtureKind::RepoWithIssue, issue.clone()); | 398 | cache.insert(FixtureKind::RepoWithIssue, issue.clone()); |
| 364 | } | 399 | } |
| 365 | 400 | ||
| @@ -707,10 +742,10 @@ impl<'a> TestContext<'a> { | |||
| 707 | 742 | ||
| 708 | /// Clear the fixture cache | 743 | /// Clear the fixture cache |
| 709 | /// | 744 | /// |
| 710 | /// This is useful for tests that want to ensure fresh fixtures | 745 | /// This clears the client's fixture cache, affecting all TestContext |
| 711 | /// even in shared mode. | 746 | /// instances using the same client. |
| 712 | pub fn clear_cache(&self) { | 747 | pub fn clear_cache(&self) { |
| 713 | let mut cache = self.cache.lock().unwrap(); | 748 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 714 | cache.clear(); | 749 | cache.clear(); |
| 715 | } | 750 | } |
| 716 | } | 751 | } |