mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
Merge remote-tracking branch 'origin/main' into browser-tools
# Conflicts: # Cargo.lock # src/channels/web/types.rs
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
//! Integration tests for the OpenAI-compatible API endpoints.
|
||||
//!
|
||||
//! Uses a mock LLM provider so no real API key is needed.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use ironclaw::channels::web::server::{GatewayState, start_server};
|
||||
use ironclaw::channels::web::sse::SseManager;
|
||||
use ironclaw::channels::web::ws::WsConnectionTracker;
|
||||
use ironclaw::error::LlmError;
|
||||
use ironclaw::llm::{
|
||||
CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
};
|
||||
|
||||
const AUTH_TOKEN: &str = "test-openai-token";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock LLM provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MockLlmProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockLlmProvider {
|
||||
fn model_name(&self) -> &str {
|
||||
"mock-model-v1"
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
}
|
||||
|
||||
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
// Echo the last user message back
|
||||
let user_msg = req
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == ironclaw::llm::Role::User)
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_else(|| "no user message".to_string());
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: format!("Mock response to: {}", user_msg),
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
response_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
req: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
// If tools are provided, return a tool call
|
||||
if let Some(tool) = req.tools.first() {
|
||||
Ok(ToolCompletionResponse {
|
||||
content: None,
|
||||
tool_calls: vec![ironclaw::llm::ToolCall {
|
||||
id: "call_mock_001".to_string(),
|
||||
name: tool.name.clone(),
|
||||
arguments: serde_json::json!({"test": true}),
|
||||
}],
|
||||
input_tokens: 15,
|
||||
output_tokens: 8,
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
response_id: None,
|
||||
})
|
||||
} else {
|
||||
Ok(ToolCompletionResponse {
|
||||
content: Some("No tools available".to_string()),
|
||||
tool_calls: vec![],
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
finish_reason: FinishReason::Stop,
|
||||
response_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
||||
Ok(vec![
|
||||
"mock-model-v1".to_string(),
|
||||
"mock-model-v2".to_string(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn start_test_server() -> (SocketAddr, Arc<GatewayState>) {
|
||||
let state = Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
workspace: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
user_id: "test-user".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: Some(Arc::new(MockLlmProvider)),
|
||||
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let bound_addr = start_server(addr, state.clone(), AUTH_TOKEN.to_string())
|
||||
.await
|
||||
.expect("Failed to start test server");
|
||||
|
||||
(bound_addr, state)
|
||||
}
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_basic() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello world"}
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["object"], "chat.completion");
|
||||
assert_eq!(body["model"], "mock-model-v1");
|
||||
assert_eq!(body["choices"][0]["finish_reason"], "stop");
|
||||
|
||||
let content = body["choices"][0]["message"]["content"].as_str().unwrap();
|
||||
assert!(
|
||||
content.contains("Hello world"),
|
||||
"Expected echo, got: {}",
|
||||
content
|
||||
);
|
||||
|
||||
// Check usage
|
||||
assert_eq!(body["usage"]["prompt_tokens"], 10);
|
||||
assert_eq!(body["usage"]["completion_tokens"], 5);
|
||||
assert_eq!(body["usage"]["total_tokens"], 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_with_system_message() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "What is 2+2?"}
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 100
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let content = body["choices"][0]["message"]["content"].as_str().unwrap();
|
||||
assert!(content.contains("2+2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_with_tools() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather?"}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
|
||||
|
||||
let tool_calls = &body["choices"][0]["message"]["tool_calls"];
|
||||
assert!(tool_calls.is_array());
|
||||
assert_eq!(tool_calls[0]["id"], "call_mock_001");
|
||||
assert_eq!(tool_calls[0]["type"], "function");
|
||||
assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_streaming() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Stream test"}
|
||||
],
|
||||
"stream": true
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Check simulated streaming header
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("x-ironclaw-streaming")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("simulated"),
|
||||
"Expected x-ironclaw-streaming: simulated header"
|
||||
);
|
||||
|
||||
let text = resp.text().await.unwrap();
|
||||
|
||||
// Should contain SSE data lines
|
||||
assert!(
|
||||
text.contains("data:"),
|
||||
"Expected SSE data lines, got: {}",
|
||||
text
|
||||
);
|
||||
// Should end with [DONE]
|
||||
assert!(
|
||||
text.contains("[DONE]"),
|
||||
"Expected [DONE] sentinel, got: {}",
|
||||
text
|
||||
);
|
||||
// Should contain the role chunk
|
||||
assert!(
|
||||
text.contains("\"role\":\"assistant\""),
|
||||
"Expected role chunk, got: {}",
|
||||
text
|
||||
);
|
||||
|
||||
// Collect all content from the chunks
|
||||
let mut full_content = String::new();
|
||||
for line in text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data:") {
|
||||
let data = data.trim();
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data)
|
||||
&& let Some(content) = chunk["choices"][0]["delta"]["content"].as_str()
|
||||
{
|
||||
full_content.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
full_content.contains("Stream test"),
|
||||
"Expected reassembled content to contain 'Stream test', got: '{}'",
|
||||
full_content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_empty_messages() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": []
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"]["message"].as_str().unwrap().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_model_mismatch() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"]["code"], "model_not_found");
|
||||
assert!(
|
||||
body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("mock-model-v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_no_auth() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
// No auth header
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_models_endpoint() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/models", addr);
|
||||
|
||||
let resp = client()
|
||||
.get(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
assert_eq!(body["object"], "list");
|
||||
let data = body["data"].as_array().unwrap();
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data[0]["id"], "mock-model-v1");
|
||||
assert_eq!(data[1]["id"], "mock-model-v2");
|
||||
assert_eq!(data[0]["object"], "model");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_models_no_auth() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/models", addr);
|
||||
|
||||
let resp = client().get(&url).send().await.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_llm_provider_returns_503() {
|
||||
// Create state WITHOUT llm_provider
|
||||
let state = Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
workspace: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
user_id: "test-user".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: None, // No LLM!
|
||||
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let bound_addr = start_server(addr, state, AUTH_TOKEN.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let url = format!("http://{}/v1/chat/completions", bound_addr);
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 503);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_body_too_large() {
|
||||
let (addr, _state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
// Build a payload over 1 MB (the gateway's DefaultBodyLimit)
|
||||
let big_content = "x".repeat(2 * 1024 * 1024);
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [{"role": "user", "content": big_content}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), 413);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Integration tests for the DM pairing flow.
|
||||
//!
|
||||
//! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed.
|
||||
//! Uses temp directory for isolation.
|
||||
|
||||
use ironclaw::cli::{PairingCommand, run_pairing_command_with_store};
|
||||
use ironclaw::pairing::PairingStore;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_store() -> (PairingStore, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairing_flow_unknown_user_to_approved() {
|
||||
let (store, _) = test_store();
|
||||
let channel = "telegram";
|
||||
|
||||
// 1. Unknown user sends first message -> upsert creates request
|
||||
let r1 = store
|
||||
.upsert_request(
|
||||
channel,
|
||||
"user_12345",
|
||||
Some(serde_json::json!({
|
||||
"chat_id": 999,
|
||||
"username": "alice"
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r1.created);
|
||||
assert!(!r1.code.is_empty());
|
||||
assert_eq!(r1.code.len(), 8);
|
||||
|
||||
// 2. List pending shows the request
|
||||
let pending = store.list_pending(channel).unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, "user_12345");
|
||||
assert_eq!(pending[0].code, r1.code);
|
||||
|
||||
// 3. User is not allowed yet
|
||||
assert!(
|
||||
!store
|
||||
.is_sender_allowed(channel, "user_12345", Some("alice"))
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// 4. Approve via code
|
||||
let approved = store.approve(channel, &r1.code).unwrap();
|
||||
assert!(approved.is_some());
|
||||
assert_eq!(approved.unwrap().id, "user_12345");
|
||||
|
||||
// 5. User is now allowed
|
||||
assert!(
|
||||
store
|
||||
.is_sender_allowed(channel, "user_12345", None)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_sender_allowed(channel, "user_12345", Some("alice"))
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// 6. Pending list is empty
|
||||
let pending_after = store.list_pending(channel).unwrap();
|
||||
assert!(pending_after.is_empty());
|
||||
|
||||
// 7. allowFrom contains the user
|
||||
let allow = store.read_allow_from(channel).unwrap();
|
||||
assert_eq!(allow, vec!["user_12345"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairing_flow_cli_approve() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "user_999", None).unwrap();
|
||||
let pending = store.list_pending("telegram").unwrap();
|
||||
let code = pending[0].code.clone();
|
||||
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::Approve {
|
||||
channel: "telegram".to_string(),
|
||||
code,
|
||||
},
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(
|
||||
store
|
||||
.is_sender_allowed("telegram", "user_999", None)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairing_reject_invalid_code() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "user_1", None).unwrap();
|
||||
|
||||
let result = store.approve("telegram", "INVALID1");
|
||||
assert!(result.unwrap().is_none());
|
||||
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::Approve {
|
||||
channel: "telegram".to_string(),
|
||||
code: "BADCODE1".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pairing_multiple_channels_isolated() {
|
||||
let (store, _) = test_store();
|
||||
|
||||
let r_telegram = store.upsert_request("telegram", "user_a", None).unwrap();
|
||||
let r_slack = store.upsert_request("slack", "user_b", None).unwrap();
|
||||
|
||||
// Each channel has its own pending
|
||||
assert_eq!(store.list_pending("telegram").unwrap().len(), 1);
|
||||
assert_eq!(store.list_pending("slack").unwrap().len(), 1);
|
||||
|
||||
// Approve in one channel doesn't affect the other
|
||||
store.approve("telegram", &r_telegram.code).unwrap();
|
||||
assert!(store.is_sender_allowed("telegram", "user_a", None).unwrap());
|
||||
assert!(!store.is_sender_allowed("slack", "user_a", None).unwrap());
|
||||
|
||||
store.approve("slack", &r_slack.code).unwrap();
|
||||
assert!(store.is_sender_allowed("slack", "user_b", None).unwrap());
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use ironclaw::channels::wasm::{
|
||||
ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint,
|
||||
WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use ironclaw::pairing::PairingStore;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test runtime for WASM channel operations.
|
||||
@@ -38,7 +39,13 @@ fn create_test_channel(
|
||||
capabilities = capabilities.with_path(path.to_string());
|
||||
}
|
||||
|
||||
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
|
||||
WasmChannel::new(
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
)
|
||||
}
|
||||
|
||||
mod router_tests {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![cfg(feature = "postgres")]
|
||||
//! Integration tests for the workspace module.
|
||||
//!
|
||||
//! Requires a running PostgreSQL with pgvector extension.
|
||||
@@ -20,6 +21,18 @@ fn get_pool() -> deadpool_postgres::Pool {
|
||||
.expect("Failed to create pool")
|
||||
}
|
||||
|
||||
/// Try to get a connection, returning None if Postgres is unreachable.
|
||||
/// Tests call this to skip gracefully in CI where no database is available.
|
||||
async fn try_connect(pool: &deadpool_postgres::Pool) -> Option<()> {
|
||||
match pool.get().await {
|
||||
Ok(_) => Some(()),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: database unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
|
||||
let conn = pool.get().await.expect("Failed to get connection");
|
||||
conn.execute(
|
||||
@@ -33,6 +46,9 @@ async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_write_and_read() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_write_read";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -58,6 +74,9 @@ async fn test_workspace_write_and_read() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_append() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_append";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -85,6 +104,9 @@ async fn test_workspace_append() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_nested_paths() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_nested";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -130,6 +152,9 @@ async fn test_workspace_nested_paths() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_delete() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_delete";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -154,6 +179,9 @@ async fn test_workspace_delete() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_memory_operations() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_memory_ops";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -182,6 +210,9 @@ async fn test_workspace_memory_operations() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_daily_log() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_daily_log";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -208,6 +239,9 @@ async fn test_workspace_daily_log() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_fts_search() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_fts_search";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -266,6 +300,9 @@ async fn test_workspace_fts_search() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_hybrid_search_with_mock_embeddings() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_hybrid_search";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -305,6 +342,9 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_list_all() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_list_all";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -330,6 +370,9 @@ async fn test_workspace_list_all() {
|
||||
#[tokio::test]
|
||||
async fn test_workspace_system_prompt() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let user_id = "test_system_prompt";
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
|
||||
@@ -41,14 +41,18 @@ async fn start_test_server() -> (
|
||||
msg_tx: tokio::sync::RwLock::new(Some(agent_tx)),
|
||||
sse: SseManager::new(),
|
||||
workspace: None,
|
||||
context_manager: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
user_id: "test-user".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: None,
|
||||
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
@@ -64,7 +68,12 @@ async fn connect_ws(
|
||||
addr: SocketAddr,
|
||||
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
|
||||
let url = format!("ws://{}/api/chat/ws?token={}", addr, AUTH_TOKEN);
|
||||
let request = url.into_client_request().unwrap();
|
||||
let mut request = url.into_client_request().unwrap();
|
||||
// Server requires an Origin header from localhost to prevent cross-site WS hijacking.
|
||||
request.headers_mut().insert(
|
||||
"Origin",
|
||||
format!("http://127.0.0.1:{}", addr.port()).parse().unwrap(),
|
||||
);
|
||||
let (stream, _response) = tokio_tungstenite::connect_async(request)
|
||||
.await
|
||||
.expect("Failed to connect WebSocket");
|
||||
@@ -162,6 +171,7 @@ async fn test_ws_thinking_event() {
|
||||
|
||||
state.sse.broadcast(SseEvent::Thinking {
|
||||
message: "analyzing...".to_string(),
|
||||
thread_id: None,
|
||||
});
|
||||
|
||||
let text = recv_text(&mut ws).await;
|
||||
@@ -286,13 +296,16 @@ async fn test_ws_multiple_events_in_sequence() {
|
||||
// Broadcast multiple events rapidly
|
||||
state.sse.broadcast(SseEvent::Thinking {
|
||||
message: "step 1".to_string(),
|
||||
thread_id: None,
|
||||
});
|
||||
state.sse.broadcast(SseEvent::ToolStarted {
|
||||
name: "shell".to_string(),
|
||||
thread_id: None,
|
||||
});
|
||||
state.sse.broadcast(SseEvent::ToolCompleted {
|
||||
name: "shell".to_string(),
|
||||
success: true,
|
||||
thread_id: None,
|
||||
});
|
||||
state.sse.broadcast(SseEvent::Response {
|
||||
content: "done".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user