From 52bad9954cdddf55ab749fd0c6387edbc766632f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 4 Nov 2025 10:25:53 +0000 Subject: docs: use Diátaxis structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TEST_STRATEGY.md | 1238 ------------------------------------------------- 1 file changed, 1238 deletions(-) delete mode 100644 docs/TEST_STRATEGY.md (limited to 'docs/TEST_STRATEGY.md') diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md deleted file mode 100644 index cc1d5b0..0000000 --- a/docs/TEST_STRATEGY.md +++ /dev/null @@ -1,1238 +0,0 @@ -# Test Strategy for ngit-grasp - -## Overview - -This document outlines the comprehensive testing strategy for ngit-grasp, including a **reusable GRASP compliance testing tool** that can validate any GRASP implementation against the protocol specification. - -## Testing Philosophy - -1. **Specification-Driven**: Tests mirror the GRASP protocol structure exactly -2. **Compliance-First**: Every requirement in the spec has a corresponding test -3. **Reusable**: Compliance tests can validate any GRASP implementation -4. **Clear Failures**: Test failures cite exact spec lines/sections -5. **Comprehensive**: Unit, integration, and compliance testing - -## Test Pyramid - -``` - ╱╲ - ╱ ╲ - ╱ E2E╲ ~ 10% End-to-end with real Git - ╱──────╲ - ╱ ╲ - ╱Compliance╲ ~ 20% GRASP spec validation - ╱────────────╲ - ╱ ╲ - ╱ Integration ╲ ~ 30% Component interaction - ╱──────────────────╲ - ╱ ╲ - ╱ Unit Tests ╲ ~ 40% Individual functions - ╱────────────────────────╲ -``` - -## GRASP Compliance Testing Tool - -### Design Goals - -1. **Reusable**: Can test ngit-grasp or any other GRASP implementation -2. **Spec-Mirrored**: Test structure matches GRASP protocol documents -3. **Clear Reporting**: Failures cite exact spec requirements -4. **Automated**: Can run in CI/CD -5. **Extensible**: Easy to add new GRASP versions (GRASP-02, GRASP-05) - -### Project Structure - -``` -grasp-compliance-tests/ -├── Cargo.toml # Standalone crate -├── README.md # Usage instructions -├── src/ -│ ├── lib.rs # Public API -│ ├── client.rs # Test client utilities -│ ├── assertions.rs # Spec-based assertions -│ └── specs/ -│ ├── mod.rs # Spec registry -│ ├── grasp_01.rs # GRASP-01 tests -│ ├── grasp_02.rs # GRASP-02 tests -│ └── grasp_05.rs # GRASP-05 tests -├── fixtures/ -│ ├── repos/ # Test repositories -│ ├── events/ # Nostr event fixtures -│ └── keys/ # Test keypairs -└── examples/ - └── test_implementation.rs # Example usage -``` - -### Spec-Mirrored Test Structure - -Each GRASP spec document maps to a test module with identical structure: - -```rust -// src/specs/grasp_01.rs - -use crate::{TestContext, SpecRequirement, ComplianceResult}; - -/// GRASP-01 - Core Service Requirements -/// Reference: https://gitworkshop.dev/danconwaydev.com/grasp/01.md -pub struct Grasp01Spec; - -impl Grasp01Spec { - /// Run all GRASP-01 compliance tests - pub async fn test_compliance(ctx: &TestContext) -> ComplianceResult { - let mut results = ComplianceResult::new("GRASP-01"); - - // Section: Nostr Relay - results.add(Self::test_nostr_relay_nip01_compliance(ctx).await); - results.add(Self::test_accepts_repository_announcements(ctx).await); - results.add(Self::test_accepts_repository_state_announcements(ctx).await); - results.add(Self::test_rejects_unlisted_announcements(ctx).await); - results.add(Self::test_accepts_related_events(ctx).await); - results.add(Self::test_serves_nip11_document(ctx).await); - results.add(Self::test_nip11_has_supported_grasps(ctx).await); - results.add(Self::test_nip11_has_repo_acceptance_criteria(ctx).await); - results.add(Self::test_nip11_has_curation_policy(ctx).await); - - // Section: Git Smart HTTP Service - results.add(Self::test_serves_git_at_correct_path(ctx).await); - results.add(Self::test_accepts_matching_pushes(ctx).await); - results.add(Self::test_rejects_mismatched_pushes(ctx).await); - results.add(Self::test_respects_recursive_maintainers(ctx).await); - results.add(Self::test_sets_head_from_state(ctx).await); - results.add(Self::test_accepts_nostr_refs(ctx).await); - results.add(Self::test_rejects_pr_branches(ctx).await); - results.add(Self::test_deletes_orphaned_nostr_refs(ctx).await); - results.add(Self::test_allows_reachable_sha1_in_want(ctx).await); - results.add(Self::test_allows_tip_sha1_in_want(ctx).await); - results.add(Self::test_serves_webpage(ctx).await); - - // Section: CORS Support - results.add(Self::test_cors_allow_origin(ctx).await); - results.add(Self::test_cors_allow_methods(ctx).await); - results.add(Self::test_cors_allow_headers(ctx).await); - results.add(Self::test_cors_options_request(ctx).await); - - results - } - - // ================================================================ - // NOSTR RELAY TESTS - // ================================================================ - - /// MUST serve a NIP-01 compliant nostr relay at `/` - /// - /// Spec: GRASP-01, Line 9-10 - /// > MUST serve a [NIP-01](https://nips.nostr.com/1) compliant nostr - /// > relay at `/` that accepts [git repository announcements]... - async fn test_nostr_relay_nip01_compliance(ctx: &TestContext) -> TestResult { - TestResult::new( - "nostr_relay_nip01_compliance", - "GRASP-01:9-10", - "MUST serve a NIP-01 compliant nostr relay at `/`", - ) - .run(async { - // Test WebSocket upgrade at / - let ws = ctx.connect_websocket("/").await?; - - // Test NIP-01 REQ/EVENT/CLOSE/NOTICE messages - ws.send_req("test-sub", vec![]).await?; - let response = ws.recv().await?; - assert_nip01_eose(response)?; - - Ok(()) - }) - .await - } - - /// MUST reject announcements that do not list the service in both - /// `clone` and `relays` tags unless implementing `GRASP-05` - /// - /// Spec: GRASP-01, Line 12-13 - /// > MUST reject [git repository announcements] that do not list the - /// > service in both `clone` and `relays` tags unless implementing `GRASP-05`. - async fn test_rejects_unlisted_announcements(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_unlisted_announcements", - "GRASP-01:12-13", - "MUST reject announcements not listing service in clone and relays", - ) - .run(async { - let event = ctx.create_announcement() - .without_clone_tag(ctx.domain()) - .build() - .await?; - - let result = ctx.send_event(event).await?; - - assert_eq!( - result.ok, false, - "Expected rejection of announcement without clone tag" - ); - assert!( - result.message.contains("clone") || result.message.contains("relays"), - "Expected rejection message to mention clone/relays requirement" - ); - - Ok(()) - }) - .await - } - - /// MUST accept other events that tag, or are tagged by, accepted announcements - /// - /// Spec: GRASP-01, Line 17-20 - /// > MUST accept other events that tag, or are tagged by, either: - /// > 1. accepted [git repository announcements]; or - /// > 2. accepted [issues] or [patches] - async fn test_accepts_related_events(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_related_events", - "GRASP-01:17-20", - "MUST accept events that tag or are tagged by accepted announcements", - ) - .run(async { - // First, create and accept an announcement - let announcement = ctx.create_announcement() - .with_clone_tag(ctx.domain()) - .with_relay_tag(ctx.domain()) - .build() - .await?; - - ctx.send_event(announcement.clone()).await?; - - // Now send an issue that tags the announcement - let issue = ctx.create_issue() - .tag_announcement(&announcement) - .build() - .await?; - - let result = ctx.send_event(issue).await?; - - assert_eq!( - result.ok, true, - "Expected acceptance of issue tagging accepted announcement" - ); - - Ok(()) - }) - .await - } - - /// MUST serve a NIP-11 document with required fields - /// - /// Spec: GRASP-01, Line 24-27 - /// > MUST serve a [NIP-11] document: - /// > 1. MUST list each supported GRASP under `supported_grasps` - /// > 2. MUST list repository acceptance criteria under `repo_acceptance_criteria` - /// > 3. MUST list curation policy under `curation` if events are curated - async fn test_serves_nip11_document(ctx: &TestContext) -> TestResult { - TestResult::new( - "serves_nip11_document", - "GRASP-01:24-27", - "MUST serve a NIP-11 document", - ) - .run(async { - let nip11 = ctx.fetch_nip11().await?; - - assert!( - nip11.contains_key("supported_nips"), - "NIP-11 document must have supported_nips" - ); - - Ok(()) - }) - .await - } - - /// NIP-11 MUST list supported GRASPs - /// - /// Spec: GRASP-01, Line 25 - /// > 1. MUST list each supported GRASP under `supported_grasps` - /// > in format `GRASP-XX` eg `GRASP-01` as a string array - async fn test_nip11_has_supported_grasps(ctx: &TestContext) -> TestResult { - TestResult::new( - "nip11_has_supported_grasps", - "GRASP-01:25", - "NIP-11 MUST list supported_grasps as string array", - ) - .run(async { - let nip11 = ctx.fetch_nip11().await?; - - let grasps = nip11.get("supported_grasps") - .ok_or("NIP-11 missing supported_grasps field")? - .as_array() - .ok_or("supported_grasps must be an array")?; - - assert!( - grasps.iter().any(|g| g.as_str() == Some("GRASP-01")), - "supported_grasps must include 'GRASP-01'" - ); - - // Validate format: GRASP-XX - for grasp in grasps { - let s = grasp.as_str().ok_or("GRASP must be a string")?; - assert!( - s.starts_with("GRASP-") && s.len() >= 8, - "GRASP format must be 'GRASP-XX', got: {}", s - ); - } - - Ok(()) - }) - .await - } - - // ================================================================ - // GIT SMART HTTP SERVICE TESTS - // ================================================================ - - /// MUST serve a git repository via git smart http at //.git - /// - /// Spec: GRASP-01, Line 31-32 - /// > MUST serve a git repository via an unauthenticated [git smart http service] - /// > at `//.git` for each accepted announcement - async fn test_serves_git_at_correct_path(ctx: &TestContext) -> TestResult { - TestResult::new( - "serves_git_at_correct_path", - "GRASP-01:31-32", - "MUST serve git at //.git", - ) - .run(async { - // Create and send announcement - let announcement = ctx.create_announcement() - .with_identifier("test-repo") - .with_clone_tag(ctx.domain()) - .with_relay_tag(ctx.domain()) - .build() - .await?; - - let npub = announcement.author_npub(); - ctx.send_event(announcement).await?; - - // Wait for repo creation - tokio::time::sleep(Duration::from_secs(2)).await; - - // Test git info/refs endpoint - let path = format!("/{}/test-repo.git/info/refs?service=git-upload-pack", npub); - let response = ctx.http_get(&path).await?; - - assert_eq!( - response.status(), 200, - "Git info/refs must return 200 OK" - ); - - assert_eq!( - response.headers().get("content-type").unwrap(), - "application/x-git-upload-pack-advertisement", - "Git info/refs must have correct content-type" - ); - - Ok(()) - }) - .await - } - - /// MUST accept pushes that match the latest state announcement - /// - /// Spec: GRASP-01, Line 34-35 - /// > MUST accept pushes via this service that match the latest - /// > [repo state announcement] on the relay, respecting the recursive maintainer set. - async fn test_accepts_matching_pushes(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_matching_pushes", - "GRASP-01:34-35", - "MUST accept pushes matching latest state announcement", - ) - .run(async { - // Setup: Create repo with announcement and state - let (announcement, state) = ctx.create_repo_with_state() - .branch("main", "a1b2c3d4...") - .build() - .await?; - - // Push matching state - let result = ctx.git_push(&announcement, "main", "a1b2c3d4...").await?; - - assert!( - result.success, - "Push matching state must succeed, got: {}", result.stderr - ); - - Ok(()) - }) - .await - } - - /// MUST reject pushes that don't match the state announcement - /// - /// Spec: GRASP-01, Line 34-35 (inverse requirement) - /// Implied by "MUST accept pushes... that match" - async fn test_rejects_mismatched_pushes(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_mismatched_pushes", - "GRASP-01:34-35", - "MUST reject pushes not matching state announcement", - ) - .run(async { - // Setup: Create repo with state pointing to commit A - let (announcement, state) = ctx.create_repo_with_state() - .branch("main", "aaaa1111...") - .build() - .await?; - - // Try to push different commit B - let result = ctx.git_push(&announcement, "main", "bbbb2222...").await; - - assert!( - result.is_err() || !result.unwrap().success, - "Push not matching state must be rejected" - ); - - Ok(()) - }) - .await - } - - /// MUST accept pushes to refs/nostr/ - /// - /// Spec: GRASP-01, Line 42-44 - /// > MUST accept pushes via this service to `refs/nostr/` but - /// > SHOULD reject if event exists on relay listing a different tip - async fn test_accepts_nostr_refs(ctx: &TestContext) -> TestResult { - TestResult::new( - "accepts_nostr_refs", - "GRASP-01:42-44", - "MUST accept pushes to refs/nostr/", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state().build().await?; - - // Create a PR event - let pr_event = ctx.create_pr_event() - .for_repo(&announcement) - .build() - .await?; - - let event_id = pr_event.id(); - - // Push to refs/nostr/ - let result = ctx.git_push( - &announcement, - &format!("refs/nostr/{}", event_id), - "commit-sha..." - ).await?; - - assert!( - result.success, - "Push to refs/nostr/ must succeed" - ); - - Ok(()) - }) - .await - } - - /// MUST reject pr/* branches - /// - /// Spec: GRASP-01, Line 42-44 (implied) - /// PRs should use refs/nostr/, not refs/heads/pr/* - async fn test_rejects_pr_branches(ctx: &TestContext) -> TestResult { - TestResult::new( - "rejects_pr_branches", - "GRASP-01:42-44", - "MUST reject refs/heads/pr/* (use refs/nostr/ instead)", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state().build().await?; - - // Try to push to pr/* branch - let result = ctx.git_push( - &announcement, - "refs/heads/pr/123", - "commit-sha..." - ).await; - - assert!( - result.is_err() || !result.unwrap().success, - "Push to refs/heads/pr/* must be rejected" - ); - - Ok(()) - }) - .await - } - - /// MUST include allow-reachable-sha1-in-want and allow-tip-sha1-in-want - /// - /// Spec: GRASP-01, Line 48-49 - /// > MUST include `allow-reachable-sha1-in-want` and `allow-tip-sha1-in-want` - /// > in advertisement and serve available oids. - async fn test_allows_tip_sha1_in_want(ctx: &TestContext) -> TestResult { - TestResult::new( - "allows_tip_sha1_in_want", - "GRASP-01:48-49", - "MUST advertise and support allow-tip-sha1-in-want", - ) - .run(async { - let (announcement, _) = ctx.create_repo_with_state() - .branch("main", "a1b2c3d4...") - .build() - .await?; - - // Fetch git capabilities - let caps = ctx.git_capabilities(&announcement).await?; - - assert!( - caps.contains("allow-tip-sha1-in-want"), - "Git advertisement must include allow-tip-sha1-in-want" - ); - - assert!( - caps.contains("allow-reachable-sha1-in-want"), - "Git advertisement must include allow-reachable-sha1-in-want" - ); - - Ok(()) - }) - .await - } - - // ================================================================ - // CORS SUPPORT TESTS - // ================================================================ - - /// MUST set Access-Control-Allow-Origin: * on ALL responses - /// - /// Spec: GRASP-01, Line 57 - /// > 1. Set `Access-Control-Allow-Origin: *` on ALL responses - async fn test_cors_allow_origin(ctx: &TestContext) -> TestResult { - TestResult::new( - "cors_allow_origin", - "GRASP-01:57", - "MUST set Access-Control-Allow-Origin: * on ALL responses", - ) - .run(async { - let paths = vec![ - "/", - "/test-npub/test-repo.git/info/refs?service=git-upload-pack", - ]; - - for path in paths { - let response = ctx.http_get(path).await?; - - assert_eq!( - response.headers().get("access-control-allow-origin").unwrap(), - "*", - "Path {} must have Access-Control-Allow-Origin: *", path - ); - } - - Ok(()) - }) - .await - } - - /// MUST respond to OPTIONS requests with 204 No Content - /// - /// Spec: GRASP-01, Line 60 - /// > 4. Respond to OPTIONS requests with 204 No Content - async fn test_cors_options_request(ctx: &TestContext) -> TestResult { - TestResult::new( - "cors_options_request", - "GRASP-01:60", - "MUST respond to OPTIONS with 204 No Content", - ) - .run(async { - let response = ctx.http_options("/test-npub/test-repo.git/info/refs").await?; - - assert_eq!( - response.status(), 204, - "OPTIONS request must return 204 No Content" - ); - - Ok(()) - }) - .await - } -} -``` - -### Test Result Reporting - -```rust -/// Test result with spec citation -pub struct TestResult { - pub name: String, - pub spec_ref: String, // e.g., "GRASP-01:12-13" - pub requirement: String, // Exact text from spec - pub passed: bool, - pub error: Option, - pub duration: Duration, -} - -impl TestResult { - /// Create a new test result - pub fn new(name: &str, spec_ref: &str, requirement: &str) -> Self { - TestResult { - name: name.to_string(), - spec_ref: spec_ref.to_string(), - requirement: requirement.to_string(), - passed: false, - error: None, - duration: Duration::default(), - } - } - - /// Run the test - pub async fn run(mut self, test_fn: F) -> Self - where - F: FnOnce() -> Fut, - Fut: Future>, - { - let start = Instant::now(); - - match test_fn().await { - Ok(()) => { - self.passed = true; - } - Err(e) => { - self.passed = false; - self.error = Some(e); - } - } - - self.duration = start.elapsed(); - self - } -} - -/// Collection of test results for a spec -pub struct ComplianceResult { - pub spec: String, - pub results: Vec, -} - -impl ComplianceResult { - pub fn report(&self) -> String { - let mut output = String::new(); - - output.push_str(&format!("\n{} Compliance Report\n", self.spec)); - output.push_str(&"=".repeat(60)); - output.push_str("\n\n"); - - let passed = self.results.iter().filter(|r| r.passed).count(); - let total = self.results.len(); - - output.push_str(&format!("Results: {}/{} passed\n\n", passed, total)); - - for result in &self.results { - let status = if result.passed { "✓" } else { "✗" }; - - output.push_str(&format!( - "{} {} ({})\n", - status, result.name, result.spec_ref - )); - - output.push_str(&format!(" Requirement: {}\n", result.requirement)); - - if let Some(error) = &result.error { - output.push_str(&format!(" Error: {}\n", error)); - } - - output.push_str(&format!(" Duration: {:?}\n\n", result.duration)); - } - - output - } -} -``` - -### Usage Example - -```rust -// examples/test_implementation.rs - -use grasp_compliance_tests::{TestContext, Grasp01Spec}; - -#[tokio::main] -async fn main() { - // Configure the implementation to test - let ctx = TestContext::builder() - .base_url("http://localhost:8080") - .websocket_url("ws://localhost:8080") - .domain("localhost:8080") - .build(); - - // Run GRASP-01 compliance tests - let results = Grasp01Spec::test_compliance(&ctx).await; - - // Print report - println!("{}", results.report()); - - // Exit with error if any tests failed - if !results.all_passed() { - std::process::exit(1); - } -} -``` - -### Integration with ngit-grasp - -In `ngit-grasp/tests/compliance.rs`: - -```rust -use grasp_compliance_tests::{TestContext, Grasp01Spec}; - -#[tokio::test] -async fn test_grasp_01_compliance() { - // Start test server - let server = start_test_server().await; - - // Configure test context - let ctx = TestContext::builder() - .base_url(&server.url()) - .websocket_url(&server.ws_url()) - .domain(&server.domain()) - .build(); - - // Run compliance tests - let results = Grasp01Spec::test_compliance(&ctx).await; - - // Assert all tests passed - assert!( - results.all_passed(), - "GRASP-01 compliance failed:\n{}", - results.report() - ); -} -``` - -## Unit Testing Strategy - -### Git Module Tests - -```rust -// src/git/parser.rs tests - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_pkt_line() { - let data = b"0006a\n"; - let (length, payload) = parse_pkt_line(data).unwrap(); - assert_eq!(length, 6); - assert_eq!(payload, b"a\n"); - } - - #[test] - fn test_parse_flush_packet() { - let data = b"0000"; - let result = parse_pkt_line(data).unwrap(); - assert_eq!(result.0, 0); - } - - #[test] - fn test_parse_ref_updates() { - let body = b"00820000000000000000000000000000000000000000 \ - a1b2c3d4e5f6789012345678901234567890abcd \ - refs/heads/main\0 report-status\n\ - 0000"; - - let updates = parse_ref_updates(body).unwrap(); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].ref_name, "refs/heads/main"); - } -} -``` - -### Authorization Module Tests - -```rust -// src/git/authorization.rs tests - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_maintainers_single() { - let events = vec![ - create_test_announcement("alice", "repo1", vec![]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert_eq!(maintainers, vec!["alice"]); - } - - #[test] - fn test_get_maintainers_recursive() { - let events = vec![ - create_test_announcement("alice", "repo1", vec!["bob"]), - create_test_announcement("bob", "repo1", vec![]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert!(maintainers.contains(&"alice".to_string())); - assert!(maintainers.contains(&"bob".to_string())); - } - - #[test] - fn test_get_maintainers_circular() { - let events = vec![ - create_test_announcement("alice", "repo1", vec!["bob"]), - create_test_announcement("bob", "repo1", vec!["alice"]), - ]; - - let maintainers = get_maintainers(&events, "alice", "repo1"); - assert_eq!(maintainers.len(), 2); - } - - #[test] - fn test_validate_state_ref_matching() { - let state = RepositoryState { - branches: HashMap::from([ - ("main".into(), "a1b2c3d4...".into()), - ]), - tags: HashMap::new(), - }; - - let update = RefUpdate { - old_oid: "0000...".into(), - new_oid: "a1b2c3d4...".into(), - ref_name: "refs/heads/main".into(), - }; - - assert!(validate_state_ref(&state, &update).is_ok()); - } - - #[test] - fn test_validate_state_ref_mismatch() { - let state = RepositoryState { - branches: HashMap::from([ - ("main".into(), "aaaa1111...".into()), - ]), - tags: HashMap::new(), - }; - - let update = RefUpdate { - old_oid: "0000...".into(), - new_oid: "bbbb2222...".into(), - ref_name: "refs/heads/main".into(), - }; - - assert!(validate_state_ref(&state, &update).is_err()); - } -} -``` - -## Integration Testing Strategy - -### Repository Lifecycle Tests - -```rust -// tests/integration/repository_lifecycle.rs - -#[tokio::test] -async fn test_repository_creation_on_announcement() { - let app = test_app().await; - - // Send repository announcement - let announcement = create_announcement() - .with_identifier("test-repo") - .with_clone_tag(app.domain()) - .with_relay_tag(app.domain()) - .sign() - .await; - - app.send_event(announcement).await.unwrap(); - - // Wait for async processing - tokio::time::sleep(Duration::from_secs(1)).await; - - // Verify repository was created - let repo_path = app.git_data_path() - .join(announcement.author_npub()) - .join("test-repo.git"); - - assert!(repo_path.exists()); - assert!(repo_path.join("HEAD").exists()); - assert!(repo_path.join("config").exists()); -} - -#[tokio::test] -async fn test_push_validation_flow() { - let app = test_app().await; - - // Create repository with state - let (announcement, state) = app.create_repo_with_state() - .branch("main", "commit-sha-123") - .build() - .await; - - // Attempt push matching state - let result = app.git_push("main", "commit-sha-123").await; - assert!(result.success); - - // Attempt push NOT matching state - let result = app.git_push("main", "different-sha-456").await; - assert!(!result.success); - assert!(result.stderr.contains("state event")); -} -``` - -### Multi-Maintainer Tests - -```rust -#[tokio::test] -async fn test_multi_maintainer_push() { - let app = test_app().await; - - // Alice creates repo, lists Bob as maintainer - let alice_announcement = create_announcement() - .author("alice") - .maintainers(vec!["bob"]) - .build(); - - app.send_event(alice_announcement).await.unwrap(); - - // Bob creates state event - let bob_state = create_state() - .author("bob") - .branch("main", "commit-123") - .build(); - - app.send_event(bob_state).await.unwrap(); - - // Bob's push should succeed - let result = app.git_push_as("bob", "main", "commit-123").await; - assert!(result.success); -} -``` - -## End-to-End Testing - -### Real Git Client Tests - -```rust -// tests/e2e/git_client.rs - -#[tokio::test] -async fn test_real_git_clone() { - let app = test_app().await; - - // Setup repository - let (announcement, _) = app.create_repo_with_commits() - .commit("Initial commit", "file.txt", "content") - .build() - .await; - - // Clone with real git client - let temp_dir = TempDir::new().unwrap(); - let clone_url = format!( - "http://{}/{}/{}.git", - app.domain(), - announcement.author_npub(), - announcement.identifier() - ); - - let output = Command::new("git") - .args(&["clone", &clone_url]) - .current_dir(&temp_dir) - .output() - .await - .unwrap(); - - assert!(output.status.success()); - assert!(temp_dir.path().join(announcement.identifier()).exists()); -} - -#[tokio::test] -async fn test_real_git_push() { - let app = test_app().await; - - // Create repository - let (announcement, keys) = app.create_repo().await; - - // Clone it - let temp_dir = TempDir::new().unwrap(); - git_clone(&app, &announcement, &temp_dir).await; - - // Make changes - let repo_dir = temp_dir.path().join(announcement.identifier()); - tokio::fs::write(repo_dir.join("new-file.txt"), "content").await.unwrap(); - - // Commit - git_commit(&repo_dir, "Add new file").await; - - // Send state event for new commit - let new_commit = git_rev_parse(&repo_dir, "HEAD").await; - app.send_state(&announcement, "main", &new_commit, &keys).await; - - // Push - let output = Command::new("git") - .args(&["push", "origin", "main"]) - .current_dir(&repo_dir) - .output() - .await - .unwrap(); - - assert!(output.status.success()); -} -``` - -## Performance Testing - -### Load Tests - -```rust -// tests/performance/load.rs - -#[tokio::test] -async fn test_concurrent_pushes() { - let app = test_app().await; - - let num_concurrent = 100; - let mut handles = vec![]; - - for i in 0..num_concurrent { - let app = app.clone(); - let handle = tokio::spawn(async move { - let (announcement, state) = app.create_repo_with_state() - .branch("main", &format!("commit-{}", i)) - .build() - .await; - - app.git_push("main", &format!("commit-{}", i)).await - }); - handles.push(handle); - } - - let results = futures::future::join_all(handles).await; - - // All should succeed - for result in results { - assert!(result.unwrap().success); - } -} - -#[tokio::test] -async fn test_event_ingestion_throughput() { - let app = test_app().await; - - let num_events = 1000; - let start = Instant::now(); - - for i in 0..num_events { - let event = create_announcement() - .with_identifier(&format!("repo-{}", i)) - .build(); - app.send_event(event).await.unwrap(); - } - - let duration = start.elapsed(); - let throughput = num_events as f64 / duration.as_secs_f64(); - - println!("Event throughput: {:.2} events/sec", throughput); - assert!(throughput > 100.0, "Throughput too low"); -} -``` - -## Test Utilities - -### Test Fixtures - -```rust -// tests/common/fixtures.rs - -pub struct TestEventBuilder { - kind: Kind, - content: String, - tags: Vec, - keys: Option, -} - -impl TestEventBuilder { - pub fn announcement() -> Self { - TestEventBuilder { - kind: Kind::RepositoryAnnouncement, - content: String::new(), - tags: vec![], - keys: None, - } - } - - pub fn with_identifier(mut self, id: &str) -> Self { - self.tags.push(Tag::Identifier(id.to_string())); - self - } - - pub fn with_clone_tag(mut self, url: &str) -> Self { - self.tags.push(Tag::new("clone", vec![url])); - self - } - - pub async fn build(self) -> Event { - let keys = self.keys.unwrap_or_else(|| Keys::generate()); - EventBuilder::new(self.kind, self.content, self.tags) - .to_event(&keys) - .await - .unwrap() - } -} -``` - -### Test Server - -```rust -// tests/common/server.rs - -pub struct TestServer { - addr: SocketAddr, - handle: JoinHandle<()>, -} - -impl TestServer { - pub async fn start() -> Self { - let config = Config { - domain: "localhost:0".to_string(), - git_data_path: TempDir::new().unwrap().into_path(), - relay_data_path: TempDir::new().unwrap().into_path(), - // ... other config - }; - - let app = create_app(config).await; - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - // Wait for server to be ready - tokio::time::sleep(Duration::from_millis(100)).await; - - TestServer { addr, handle } - } - - pub fn url(&self) -> String { - format!("http://{}", self.addr) - } - - pub fn ws_url(&self) -> String { - format!("ws://{}", self.addr) - } -} -``` - -## CI/CD Integration - -### GitHub Actions Workflow - -```yaml -# .github/workflows/test.yml - -name: Test - -on: [push, pull_request] - -jobs: - unit-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Run unit tests - run: cargo test --lib - - integration-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Install Git - run: sudo apt-get install -y git - - name: Run integration tests - run: cargo test --test '*' - - compliance-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - name: Run GRASP-01 compliance tests - run: cargo test --test compliance - - name: Generate compliance report - run: cargo run --example compliance-report > compliance-report.txt - - name: Upload compliance report - uses: actions/upload-artifact@v3 - with: - name: compliance-report - path: compliance-report.txt -``` - -## Test Coverage - -### Target Coverage - -- **Unit Tests**: >80% line coverage -- **Integration Tests**: All critical paths -- **Compliance Tests**: 100% of GRASP-01 requirements -- **E2E Tests**: Key user workflows - -### Measuring Coverage - -```bash -# Install tarpaulin -cargo install cargo-tarpaulin - -# Run with coverage -cargo tarpaulin --out Html --output-dir coverage - -# View report -open coverage/index.html -``` - -## Documentation Testing - -### Doc Tests - -```rust -/// Parse a pkt-line from Git protocol -/// -/// # Examples -/// -/// ``` -/// use ngit_grasp::git::parse_pkt_line; -/// -/// let data = b"0006a\n"; -/// let (length, payload) = parse_pkt_line(data).unwrap(); -/// assert_eq!(length, 6); -/// assert_eq!(payload, b"a\n"); -/// ``` -pub fn parse_pkt_line(data: &[u8]) -> Result<(usize, &[u8])> { - // implementation -} -``` - -## Summary - -This comprehensive test strategy ensures: - -1. **Spec Compliance**: Every GRASP requirement has a corresponding test -2. **Reusability**: Compliance tests can validate any GRASP implementation -3. **Clear Failures**: Test failures cite exact spec lines -4. **Comprehensive Coverage**: Unit, integration, compliance, and E2E tests -5. **Maintainability**: Tests mirror spec structure for easy updates - -The compliance testing tool is a standalone crate that can be: -- Used by ngit-grasp for self-validation -- Published for other GRASP implementations to use -- Updated as new GRASP specs are released (GRASP-02, GRASP-05) -- Run in CI/CD for continuous compliance verification -- cgit v1.2.3