use base64::{engine::general_purpose::STANDARD, Engine as _};
use opencode_voice::bridge::client::OpenCodeBridge;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn bind_listener() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await.unwrap_or(0);
String::from_utf8_lossy(&buf[..n]).to_string()
}
async fn send_ok(stream: &mut tokio::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await.unwrap();
}
#[test]
fn test_get_base_url() {
let bridge = OpenCodeBridge::new("http://localhost", 4096, None);
assert_eq!(bridge.get_base_url(), "http://localhost:4096");
}
#[test]
fn test_get_base_url_trailing_slash_stripped() {
let bridge = OpenCodeBridge::new("http://localhost/", 1234, None);
assert_eq!(bridge.get_base_url(), "http://localhost:1234");
}
#[tokio::test]
async fn test_is_connected_returns_false_when_server_not_running() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
assert!(!bridge.is_connected().await);
}
#[tokio::test]
async fn test_append_prompt_sends_correct_request() {
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.append_prompt("hello", None, None)
.await
.expect("append_prompt should succeed");
let req = captured.lock().await.clone();
assert!(
req.starts_with("POST /tui/append-prompt"),
"Expected POST /tui/append-prompt, got: {}",
req.lines().next().unwrap_or("")
);
assert!(
req.contains(r#""text":"hello""#) || req.contains(r#""text": "hello""#),
"Request body should contain text field: {}",
req
);
}
#[tokio::test]
async fn test_auth_header_sent_when_password_set() {
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let password = "s3cr3t";
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, Some(password.to_string()));
bridge
.append_prompt("test", None, None)
.await
.expect("append_prompt should succeed");
let req = captured.lock().await.clone();
let expected_creds = STANDARD.encode(format!(":{}", password));
let expected_value = format!("Basic {}", expected_creds);
let req_lower = req.to_lowercase();
let expected_lower = format!("authorization: {}", expected_value.to_lowercase());
assert!(
req_lower.contains(&expected_lower),
"Request should contain auth header 'authorization: {}', got:\n{}",
expected_value,
req
);
}
#[tokio::test]
async fn test_no_auth_header_when_no_password() {
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.append_prompt("test", None, None)
.await
.expect("append_prompt should succeed");
let req = captured.lock().await.clone();
assert!(
!req.contains("Authorization:"),
"Request should NOT contain Authorization header when no password is set"
);
}
#[tokio::test]
async fn test_reply_permission_sends_correct_request() {
use opencode_voice::approval::types::PermissionReply;
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.reply_permission("perm-123", PermissionReply::Once, None)
.await
.expect("reply_permission should succeed");
let req = captured.lock().await.clone();
assert!(
req.starts_with("POST /permission/perm-123/reply"),
"Expected POST /permission/perm-123/reply, got: {}",
req.lines().next().unwrap_or("")
);
assert!(
req.contains(r#""reply":"once""#) || req.contains(r#""reply": "once""#),
"Request body should contain reply field: {}",
req
);
}
#[tokio::test]
async fn test_reject_question_sends_correct_request() {
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.reject_question("q-456")
.await
.expect("reject_question should succeed");
let req = captured.lock().await.clone();
assert!(
req.starts_with("POST /question/q-456/reject"),
"Expected POST /question/q-456/reject, got: {}",
req.lines().next().unwrap_or("")
);
}
#[tokio::test]
async fn test_is_connected_returns_true_when_server_running() {
let (listener, port) = bind_listener().await;
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf).await;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
assert!(bridge.is_connected().await);
}
#[tokio::test]
async fn test_append_prompt_with_query_params() {
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.append_prompt("hello", Some("/home/user"), Some("/workspace"))
.await
.expect("append_prompt should succeed");
let req = captured.lock().await.clone();
let first_line = req.lines().next().unwrap_or("");
assert!(
first_line.contains("directory="),
"Request URL should contain directory param: {}",
first_line
);
assert!(
first_line.contains("workspace="),
"Request URL should contain workspace param: {}",
first_line
);
}
#[tokio::test]
async fn test_reply_permission_always_with_message() {
use opencode_voice::approval::types::PermissionReply;
let (listener, port) = bind_listener().await;
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let req = read_request(&mut stream).await;
*captured_clone.lock().await = req;
send_ok(&mut stream, "{}").await;
});
let bridge = OpenCodeBridge::new("http://127.0.0.1", port, None);
bridge
.reply_permission("perm-789", PermissionReply::Always, Some("approved by voice"))
.await
.expect("reply_permission should succeed");
let req = captured.lock().await.clone();
assert!(
req.contains(r#""reply":"always""#) || req.contains(r#""reply": "always""#),
"Body should contain always reply: {}",
req
);
assert!(
req.contains("approved by voice"),
"Body should contain the message: {}",
req
);
}