periplon 0.2.0

Rust SDK for building multi-agent AI workflows and automation
Documentation
# Testing Utilities

Comprehensive testing utilities for the Periplon SDK, providing mock implementations and builder patterns for all major service interfaces.

## Overview

The testing module provides:

- **Mock Services**: Drop-in replacements for MCP servers, permission services, and hook services
- **Test Builders**: Convenient builders for creating test messages, notifications, and contexts
- **Verification Tools**: Call logs and decision tracking for asserting test behavior

## Mock Services

### MockMcpServer

Mock MCP server for testing tool integrations without requiring external services.

#### Features

- Custom tool handlers with flexible logic
- Static response tools for simple cases
- Error-generating tools for testing error handling
- Call logging for verification
- Tool call counting and argument matching

#### Example

```rust
use periplon_sdk::testing::MockMcpServer;
use periplon_sdk::ports::secondary::McpServer;
use serde_json::json;

#[tokio::test]
async fn test_mcp_integration() {
    let mut server = MockMcpServer::new("test-server");

    // Add a custom tool with logic
    server.with_tool(
        "calculate",
        "Performs math operations",
        json!({"type": "object"}),
        |args| {
            let a = args.get("a").and_then(|v| v.as_f64()).unwrap_or(0.0);
            let b = args.get("b").and_then(|v| v.as_f64()).unwrap_or(0.0);
            Ok(json!({"result": a + b}))
        }
    );

    // Add a static response tool
    server.with_static_tool(
        "status",
        "Returns server status",
        json!({}),
        json!({"status": "healthy"})
    );

    // Test tool execution
    let result = server.call_tool("calculate", json!({"a": 5, "b": 3})).await.unwrap();
    assert_eq!(result.content["result"], 8.0);

    // Verify call log
    assert_eq!(server.call_count("calculate"), 1);
    assert!(server.was_called_with("calculate", &json!({"a": 5, "b": 3})));
}
```

### MockPermissionService

Mock permission service for testing authorization flows and permission-based logic.

#### Features

- Allow-all, deny-all, or ask-all policies
- Per-tool permission rules
- Custom permission handlers
- Input transformation/sanitization
- Decision logging and verification

#### Example

```rust
use periplon_sdk::testing::{MockPermissionService, PermissionContextBuilder};
use periplon_sdk::domain::PermissionDecision;
use periplon_sdk::ports::secondary::PermissionService;
use serde_json::json;

#[tokio::test]
async fn test_permission_logic() {
    let mut service = MockPermissionService::new();

    // Allow specific tools
    service.allow_tool("Read");

    // Deny dangerous tools
    service.deny_tool("Bash", "Bash not allowed in tests");

    // Custom handler for conditional logic
    service.with_handler("Write", |_tool, input, _ctx| {
        let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("");
        if path.starts_with("/tmp/") {
            PermissionDecision::Allow { updated_input: None }
        } else {
            PermissionDecision::Deny {
                reason: "Only /tmp writes allowed".to_string()
            }
        }
    });

    let ctx = PermissionContextBuilder::new().build();

    // Test allowed tool
    let decision = service.can_use_tool("Read", &json!({}), ctx.clone()).await.unwrap();
    assert!(matches!(decision, PermissionDecision::Allow { .. }));

    // Test denied tool
    let decision = service.can_use_tool("Bash", &json!({}), ctx.clone()).await.unwrap();
    assert!(matches!(decision, PermissionDecision::Deny { .. }));

    // Verify logging
    assert!(service.was_allowed("Read"));
    assert!(service.was_denied("Bash"));
}
```

### MockHookService

Mock hook service for testing lifecycle event handling and hook execution.

#### Features

- Continue-all or stop-all policies
- Per-event custom handlers
- Pre-defined hook responses (continue with message, stop with reason)
- Execution logging
- Event triggering verification

#### Example

```rust
use periplon_sdk::testing::{MockHookService, HookInputBuilder};
use periplon_sdk::domain::HookJSONOutput;
use periplon_sdk::ports::secondary::{HookEvent, HookService};
use serde_json::json;

#[tokio::test]
async fn test_hook_execution() {
    let mut service = MockHookService::new();

    // Add a pre-tool-use hook
    service.with_hook(HookEvent::PreToolUse, |input, _ctx| {
        if let periplon_sdk::domain::HookInput::PreToolUse { tool_name, tool_input, .. } = input {
            if tool_name == "Bash" {
                if let Some(cmd) = tool_input.get("command").and_then(|c| c.as_str()) {
                    if cmd.contains("rm -rf") {
                        return HookJSONOutput::Sync {
                            should_continue: Some(false),
                            suppress_output: None,
                            stop_reason: Some("Dangerous command blocked".to_string()),
                            decision: None,
                            system_message: None,
                            reason: None,
                            hook_specific_output: None,
                        };
                    }
                }
            }
        }

        HookJSONOutput::Sync {
            should_continue: Some(true),
            suppress_output: None,
            stop_reason: None,
            decision: None,
            system_message: None,
            reason: None,
            hook_specific_output: None,
        }
    });

    let ctx = periplon_sdk::domain::HookContext { signal: None };

    // Test dangerous command
    let input = HookInputBuilder::pre_tool_use("Bash", json!({"command": "sudo rm -rf /"}));
    let result = service.execute_hook(HookEvent::PreToolUse, input, ctx).await.unwrap();

    if let HookJSONOutput::Sync { should_continue, stop_reason, .. } = result {
        assert_eq!(should_continue, Some(false));
        assert!(stop_reason.is_some());
    }

    // Verify execution log
    assert!(service.was_triggered(&HookEvent::PreToolUse));
}
```

## Test Builders

### MessageBuilder

Build test messages with content blocks.

```rust
use periplon_sdk::testing::MessageBuilder;
use serde_json::json;

// Create user message
let msg = MessageBuilder::user("What is 2 + 2?");

// Create assistant message with multiple blocks
let msg = MessageBuilder::new()
    .text("I'll calculate that for you.")
    .tool_use("calc-1", "Calculate", json!({"expr": "2+2"}))
    .tool_result("calc-1", json!({"result": 4}), Some(false))
    .text("The answer is 4.")
    .build_assistant();
```

### NotificationBuilder

Build notification specs for testing notification delivery.

```rust
use periplon_sdk::testing::NotificationBuilder;
use periplon_sdk::dsl::NotificationPriority;

let notification = NotificationBuilder::new("Task completed")
    .title("Success")
    .priority(NotificationPriority::High)
    .console()
    .file("/var/log/tasks.log")
    .ntfy("https://ntfy.sh", "my-topic")
    .build();
```

### HookInputBuilder

Build hook inputs for testing hook execution.

```rust
use periplon_sdk::testing::HookInputBuilder;
use serde_json::json;

// Pre-tool-use hook input
let input = HookInputBuilder::pre_tool_use("Read", json!({"file_path": "test.txt"}));

// Post-tool-use hook input
let input = HookInputBuilder::post_tool_use(
    "Read",
    json!({"file_path": "test.txt"}),
    json!({"content": "file contents"})
);

// User prompt submit hook
let input = HookInputBuilder::user_prompt_submit("What is this file?");

// Stop hook
let input = HookInputBuilder::stop();
```

### PermissionContextBuilder

Build permission contexts for testing permission queries.

```rust
use periplon_sdk::testing::PermissionContextBuilder;

let ctx = PermissionContextBuilder::new().build();
```

## Complete Integration Test Example

```rust
use periplon_sdk::testing::{
    MockMcpServer, MockPermissionService, MockHookService,
    PermissionContextBuilder, HookInputBuilder
};
use periplon_sdk::ports::secondary::{McpServer, PermissionService, HookService, HookEvent};
use periplon_sdk::domain::PermissionDecision;
use serde_json::json;

#[tokio::test]
async fn test_complete_workflow() {
    // Setup all mock services
    let mut mcp_server = MockMcpServer::new("test-server");
    mcp_server.with_static_tool("status", "Check status", json!({}), json!({"ok": true}));

    let mut permission_service = MockPermissionService::allow_all();
    permission_service.deny_tool("Bash", "No bash allowed");

    let hook_service = MockHookService::continue_all();

    // Test MCP server
    let result = mcp_server.call_tool("status", json!({})).await.unwrap();
    assert_eq!(result.content["ok"], true);

    // Test permission service
    let ctx = PermissionContextBuilder::new().build();
    let decision = permission_service.can_use_tool("Read", &json!({}), ctx).await.unwrap();
    assert!(matches!(decision, PermissionDecision::Allow { .. }));

    // Test hook service
    let hook_ctx = periplon_sdk::domain::HookContext { signal: None };
    let input = HookInputBuilder::pre_tool_use("Read", json!({}));
    let _result = hook_service.execute_hook(HookEvent::PreToolUse, input, hook_ctx).await.unwrap();

    // Verify all services logged operations
    assert_eq!(mcp_server.call_count("status"), 1);
    assert!(permission_service.was_allowed("Read"));
    assert!(hook_service.was_triggered(&HookEvent::PreToolUse));
}
```

## Best Practices

1. **Use Specific Assertions**: Verify exact behavior rather than just "no errors"
2. **Check Call Logs**: Always verify that services were called as expected
3. **Test Error Paths**: Use `with_error_tool` and deny rules to test error handling
4. **Isolate Tests**: Each test should create its own mock instances
5. **Clear Logs**: Call `clear_log()` if reusing mocks between test phases

## See Also

- [test_utilities_demo.rs]../../../tests/test_utilities_demo.rs - Complete working examples
- [API Documentation]https://docs.rs/periplon-sdk - Full API reference