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/reference/test-strategy.md | 1238 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1238 insertions(+) create mode 100644 docs/reference/test-strategy.md (limited to 'docs/reference/test-strategy.md') diff --git a/docs/reference/test-strategy.md b/docs/reference/test-strategy.md new file mode 100644 index 0000000..cc1d5b0 --- /dev/null +++ b/docs/reference/test-strategy.md @@ -0,0 +1,1238 @@ +# 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