pywatt_sdk 0.5.3

Standardized SDK for building PyWatt modules in Rust
Documentation
// Integration tests for bootstrap functionality
use pywatt_sdk::{
    builder::ModuleBuilder, OrchestratorInit, AnnouncedEndpoint,
    communication::ipc_types::{Init, ListenAddress, OrchestratorToModule},
};
use secrecy::SecretString;

use std::collections::HashMap;
use std::io::Cursor;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::pin::Pin;
use std::sync::Once;
use std::task::{Context, Poll};
use tokio::io::{AsyncBufRead, AsyncRead};

// Mock stdin for testing read_init
struct MockStdin {
    cursor: Cursor<Vec<u8>>,
}

impl MockStdin {
    #[allow(dead_code)]
    fn new(data: &str) -> Self {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(data.as_bytes());
        Self {
            cursor: Cursor::new(bytes),
        }
    }
}

// Implement AsyncRead
impl AsyncRead for MockStdin {
    fn poll_read(
        self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
        buf: &mut tokio::io::ReadBuf<'_>,
    ) -> Poll<std::io::Result<()>> {
        let this = self.get_mut();
        let cursor_buf = this.cursor.get_ref();
        let pos = this.cursor.position() as usize;

        let remaining = &cursor_buf[pos..];
        let amt = std::cmp::min(remaining.len(), buf.remaining());

        if amt == 0 {
            return Poll::Ready(Ok(()));
        }

        buf.put_slice(&remaining[..amt]);
        this.cursor.set_position((pos + amt) as u64);

        Poll::Ready(Ok(()))
    }
}

impl AsyncBufRead for MockStdin {
    fn poll_fill_buf(
        self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
    ) -> Poll<std::io::Result<&[u8]>> {
        let this = self.get_mut();
        let buf = this.cursor.get_ref();
        let pos = this.cursor.position() as usize;
        let remaining = &buf[pos..];
        Poll::Ready(Ok(remaining))
    }

    fn consume(self: Pin<&mut Self>, amt: usize) {
        let this = self.get_mut();
        let pos = this.cursor.position() as usize;
        this.cursor.set_position((pos + amt) as u64);
    }
}

// Ensure logging is only initialized once across all tests
static LOGGING_INIT: Once = Once::new();

fn setup_logging() {
    LOGGING_INIT.call_once(|| {
        // Initialize logging with no-op to avoid actual logger initialization
        // This is needed because bootstrap_module calls init_module
    });
}

#[tokio::test]
async fn test_init_structure() {
    // Create a test Init structure
    let mut env = HashMap::new();
    env.insert("TEST_SECRET".to_string(), "test_value".to_string());

    let init = Init::new(
        "http://localhost:8000".to_string(),
        "test-module".to_string(),
        ListenAddress::Tcp(SocketAddr::new(
            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
            8080,
        )),
    ).with_env(env);

    // Serialize the structure to JSON
    let json = serde_json::to_string(&OrchestratorToModule::Init(init.clone())).unwrap();

    // Deserialize from JSON
    let parsed: OrchestratorToModule = serde_json::from_str(&json).unwrap();

    // Verify structure contents
    match parsed {
        OrchestratorToModule::Init(init_blob) => {
            assert_eq!(init_blob.orchestrator_api, "http://localhost:8000");
            assert_eq!(init_blob.module_id, "test-module");
            assert_eq!(init_blob.env.len(), 1);
            assert_eq!(init_blob.env.get("TEST_SECRET").unwrap(), "test_value");
        }
        _ => panic!("Expected Init message"),
    }
}

#[tokio::test]
async fn test_init_error_cases() {
    // Test cases for JSON parsing errors
    let invalid_json = r#"{"invalid json"#;
    let result = serde_json::from_str::<Init>(invalid_json);
    assert!(result.is_err());

    // Test case for invalid/missing fields
    let missing_fields = r#"{"module_id": "test"}"#;
    let result = serde_json::from_str::<Init>(missing_fields);
    assert!(result.is_err());
}

#[tokio::test]
async fn test_bootstrap_state_builder() {
    setup_logging();

    // Create a simple state builder function
    let state_builder = |init: &Init, secrets: Vec<secrecy::SecretString>| {
        let mut state = HashMap::new();
        state.insert("module_id".to_string(), init.module_id.clone());
        state.insert("secret_count".to_string(), secrets.len().to_string());
        state
    };

    // Prepare endpoints for announcement (we're not actually using them in this test)
    let _endpoints = [AnnouncedEndpoint {
        path: "/api/test".to_string(),
        methods: vec!["GET".to_string()],
        auth: None,
    }];

    // In a real test, we would:
    // 1. Set up a mock stdin with a valid Init message
    // 2. Call bootstrap_module
    // 3. Verify the AppState returned

    // Instead, we'll just validate our state builder logic
    let mut env = HashMap::new();
    env.insert("TEST_SECRET".to_string(), "test_value".to_string());

    let init = Init::new(
        "http://localhost:8000".to_string(),
        "test-module".to_string(),
        ListenAddress::Tcp(SocketAddr::new(
            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
            8080,
        )),
    ).with_env(env);

            let secrets = vec![secrecy::SecretString::new("test_secret".to_string().into())];

    let state = state_builder(&init, secrets);
    assert_eq!(state.get("module_id").unwrap(), "test-module");
    assert_eq!(state.get("secret_count").unwrap(), "1");
}

fn create_mock_orchestrator_init() -> OrchestratorInit {
    OrchestratorInit::new(
        "http://localhost:1234".to_string(),
        "test_module".to_string(),
        ListenAddress::Tcp("127.0.0.1:0".parse().unwrap()),
    )
    .with_env(HashMap::new())
}

// Helper to create mock secrets for AppState builder
// This will likely need adjustment once OrchestratorInit structure is clear
#[allow(dead_code)]
fn create_mock_secrets() -> Vec<SecretString> {
                vec![secrecy::SecretString::new(
        "test_secret_value".to_string().into(),
    )]
}

// Basic test for state builder function
#[tokio::test]
async fn test_module_builder_with_state_initialization() {
    let init = create_mock_orchestrator_init();

    let state_builder =
        |_init: &OrchestratorInit, _secrets: Vec<SecretString>| -> HashMap<String, String> {
            let mut state = HashMap::new();
            state.insert("module_id".to_string(), _init.module_id.clone());
            state.insert("secret_count".to_string(), "0".to_string());
            state
        };

    // Test the state builder directly without trying to create AppState
    let user_state = state_builder(&init, vec![]);

    assert_eq!(user_state.get("module_id").unwrap(), "test_module");
    assert_eq!(user_state.get("secret_count").unwrap(), "0");
}

#[tokio::test]
async fn test_module_builder_compiles_with_state() {
    // This test primarily checks if ModuleBuilder can be instantiated
    // and its state closure compiles with a mock OrchestratorInit.
    // It does not run .build() to avoid needing a mock IPC environment.

    let _builder = ModuleBuilder::new().state(
        |_init: &OrchestratorInit, _secrets: Vec<SecretString>| -> HashMap<String, String> {
            let mut state = HashMap::new();
            state.insert("module_id".to_string(), _init.module_id.clone());
            state.insert("secret_count".to_string(), _secrets.len().to_string());
            state
        },
    );

    // To actually test .build(), we would need a more complex setup
    // with mocked stdin/stdout for IPC messages.
}