#![expect(
clippy::expect_used,
reason = "integration tests use concise failure handling"
)]
#![expect(
clippy::unwrap_used,
reason = "integration tests use concise failure handling"
)]
use opencode_rs::ClientBuilder;
use opencode_rs::types::permission::PermissionReply;
use opencode_rs::types::v2::permission::PermissionReplyRequest;
use opencode_rs::types::v2::question::QuestionReply;
use opencode_rs::types::v2::session::CreateSessionRequest;
use opencode_rs::types::v2::session::Prompt;
use opencode_rs::types::v2::session::SessionDelivery;
use opencode_rs::types::v2::session::SessionListOrder;
use opencode_rs::types::v2::session::SessionListParams;
use opencode_rs::types::v2::session::SessionPromptRequest;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
fn expect_ok<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) -> T {
result.expect(context)
}
fn unwrap_some<T>(value: Option<T>) -> T {
value.unwrap()
}
#[tokio::test]
async fn v2_health_and_location_use_expected_shapes() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"healthy": true
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/location"))
.and(query_param("location[directory]", "/tmp/project"))
.and(query_param("location[workspace]", "workspace-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"directory": "/tmp/project",
"workspaceID": "workspace-1",
"project": {"id": "prj_1", "directory": "/tmp/project"}
})))
.mount(&mock)
.await;
let client = expect_ok(
ClientBuilder::new()
.base_url(mock.uri())
.directory("/tmp/project")
.workspace("workspace-1")
.build(),
"wiremock test client should build",
);
let health = client.v2().health().get().await.unwrap();
assert!(health.healthy);
let location = client.v2().location().get().await.unwrap();
assert_eq!(location.directory.as_deref(), Some("/tmp/project"));
assert_eq!(location.workspace_id.as_deref(), Some("workspace-1"));
}
#[tokio::test]
async fn v2_session_and_message_groups_deserialize_envelopes() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/session"))
.and(query_param("location[directory]", "/tmp/project"))
.and(query_param("order", "desc"))
.and(query_param("limit", "25"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{
"id": "ses_1",
"title": "Session One",
"projectID": "prj_1",
"location": {"directory": "/tmp/project"},
"time": {"created": 1, "updated": 2}
}
],
"cursor": {"next": "cursor-next"}
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/session"))
.and(body_json(serde_json::json!({"agent": "Plan"})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"id": "ses_new",
"title": "",
"location": {"directory": "/tmp/project"}
}
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/session/ses_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {"id": "ses_1", "title": "Session One", "location": {"directory": "/tmp/project"}}
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/session/ses_1/prompt"))
.and(body_json(serde_json::json!({
"prompt": {"text": "hello"},
"delivery": "queue"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"admittedSeq": 5,
"id": "msg_1",
"sessionID": "ses_1",
"prompt": {"text": "hello"},
"delivery": "queue",
"timeCreated": 123
}
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/session/ses_1/message"))
.and(query_param("cursor", "cursor-next"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"type": "assistant", "id": "msg_1"}],
"cursor": {"previous": "cursor-prev"}
})))
.mount(&mock)
.await;
let client = ClientBuilder::new()
.base_url(mock.uri())
.directory("/tmp/project")
.build()
.unwrap();
let sessions = client
.v2()
.session()
.list(&SessionListParams {
limit: Some(25),
order: Some(SessionListOrder::Desc),
..Default::default()
})
.await
.unwrap();
assert_eq!(sessions.data[0].id, "ses_1");
assert_eq!(
sessions.cursor.and_then(|c| c.next).as_deref(),
Some("cursor-next")
);
let created = client
.v2()
.session()
.create(&CreateSessionRequest {
agent: Some("Plan".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(created.data.id, "ses_new");
let got = client.v2().session().get("ses_1").await.unwrap();
assert_eq!(got.data.title, "Session One");
let admitted = client
.v2()
.session()
.prompt(
"ses_1",
&SessionPromptRequest {
id: None,
prompt: Prompt {
text: "hello".to_string(),
..Default::default()
},
delivery: Some(SessionDelivery::Queue),
resume: None,
extra: serde_json::Value::Null,
},
)
.await
.unwrap();
assert_eq!(admitted.data.session_id, "ses_1");
let messages = client
.v2()
.message()
.list("ses_1", Some("cursor-next"))
.await
.unwrap();
assert_eq!(messages.data.len(), 1);
assert_eq!(
messages.cursor.and_then(|c| c.previous).as_deref(),
Some("cursor-prev")
);
}
#[tokio::test]
async fn v2_model_and_provider_groups_use_location_wrapped_responses() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/model"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"id": "claude-sonnet", "providerID": "anthropic"}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/provider"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"id": "anthropic", "name": "Anthropic"}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/provider/anthropic"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": {"id": "anthropic", "name": "Anthropic"}
})))
.mount(&mock)
.await;
let client = ClientBuilder::new()
.base_url(mock.uri())
.directory("/tmp/project")
.build()
.unwrap();
let models = client.v2().model().list().await.unwrap();
assert_eq!(models.location.directory.as_deref(), Some("/tmp/project"));
assert_eq!(models.data.len(), 1);
let providers = client.v2().provider().list().await.unwrap();
assert_eq!(providers.data.len(), 1);
let provider = client.v2().provider().get("anthropic").await.unwrap();
assert_eq!(provider.data["id"], "anthropic");
}
#[tokio::test]
async fn v2_permission_and_question_groups_support_list_and_204_replies() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/permission/request"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{
"id": "per_1",
"sessionID": "ses_1",
"action": "fs.write",
"resources": ["/tmp/project/src/lib.rs"]
}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/session/ses_1/permission"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{
"id": "per_1",
"sessionID": "ses_1",
"action": "fs.write",
"resources": ["/tmp/project/src/lib.rs"]
}]
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/session/ses_1/permission/per_1/reply"))
.and(body_json(
serde_json::json!({"reply": "always", "message": "ok"}),
))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/question/request"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{
"id": "que_1",
"sessionID": "ses_1",
"questions": [{"question": "Continue?"}]
}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/session/ses_1/question"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{
"id": "que_1",
"sessionID": "ses_1",
"questions": [{"question": "Continue?"}]
}]
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/session/ses_1/question/que_1/reply"))
.and(body_json(serde_json::json!({"answers": [["Yes"]]})))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/session/ses_1/question/que_1/reject"))
.and(body_json(serde_json::json!({})))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
let client = ClientBuilder::new()
.base_url(mock.uri())
.directory("/tmp/project")
.build()
.unwrap();
let permissions = client.v2().permission().list_requests().await.unwrap();
assert_eq!(permissions.data.len(), 1);
assert_eq!(
permissions.location.directory.as_deref(),
Some("/tmp/project")
);
let session_permissions = client
.v2()
.permission()
.list_session_requests("ses_1")
.await
.unwrap();
assert_eq!(session_permissions.data[0].id, "per_1");
client
.v2()
.permission()
.reply(
"ses_1",
"per_1",
&PermissionReplyRequest {
reply: PermissionReply::Always,
message: Some("ok".to_string()),
},
)
.await
.unwrap();
let questions = client.v2().question().list_requests().await.unwrap();
assert_eq!(questions.data.len(), 1);
let session_questions = client
.v2()
.question()
.list_session_requests("ses_1")
.await
.unwrap();
assert_eq!(session_questions.data[0].id, "que_1");
client
.v2()
.question()
.reply(
"ses_1",
"que_1",
&QuestionReply {
answers: vec![vec!["Yes".to_string()]],
},
)
.await
.unwrap();
client
.v2()
.question()
.reject("ses_1", "que_1")
.await
.unwrap();
}
#[tokio::test]
async fn v2_optional_connector_fs_and_reference_groups_cover_json_only_endpoints() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/connector"))
.and(query_param("location[directory]", "/tmp/project"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"id": "con_1", "name": "GitHub"}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/connector/con_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": {"id": "con_1", "name": "GitHub"}
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/connector/con_1/connect/key"))
.and(body_json(
serde_json::json!({"methodID": "key", "key": "secret", "inputs": {}}),
))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/connector/con_1/connect/oauth"))
.and(body_json(
serde_json::json!({"methodID": "oauth", "inputs": {}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": {"attemptID": "att_1", "url": "https://example.com/oauth"}
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/connector/oauth/att_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": {"status": "pending"}
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/api/connector/oauth/att_1/complete"))
.and(body_json(serde_json::json!({"code": "abc123"})))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
Mock::given(method("DELETE"))
.and(path("/api/connector/oauth/att_1"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/fs/list"))
.and(query_param("path", "src"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"path": "src/lib.rs", "type": "file", "mime": "text/x-rust"}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/fs/find"))
.and(query_param("query", "lib"))
.and(query_param("type", "file"))
.and(query_param("limit", "10"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"path": "src/lib.rs", "type": "file", "mime": "text/x-rust"}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/api/reference"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"location": {"directory": "/tmp/project"},
"data": [{"name": "docs", "path": "/tmp/project/docs"}]
})))
.mount(&mock)
.await;
let client = ClientBuilder::new()
.base_url(mock.uri())
.directory("/tmp/project")
.build()
.unwrap();
let connectors = client.v2().connector().list().await.unwrap();
assert_eq!(connectors.data.len(), 1);
let connector = client.v2().connector().get("con_1").await.unwrap();
assert_eq!(unwrap_some(connector.data)["id"], "con_1");
client
.v2()
.connector()
.connect_key(
"con_1",
&serde_json::json!({"methodID": "key", "key": "secret", "inputs": {}}),
)
.await
.unwrap();
let attempt = client
.v2()
.connector()
.connect_oauth_begin(
"con_1",
&serde_json::json!({"methodID": "oauth", "inputs": {}}),
)
.await
.unwrap();
assert_eq!(attempt.data["attemptID"], "att_1");
let status = client.v2().connector().oauth_status("att_1").await.unwrap();
assert_eq!(status.data["status"], "pending");
client
.v2()
.connector()
.oauth_complete("att_1", &serde_json::json!({"code": "abc123"}))
.await
.unwrap();
client.v2().connector().oauth_cancel("att_1").await.unwrap();
let entries = client.v2().fs().list(Some("src")).await.unwrap();
assert_eq!(entries.data.len(), 1);
let found = client
.v2()
.fs()
.find("lib", Some("file"), Some(10))
.await
.unwrap();
assert_eq!(found.data.len(), 1);
let references = client.v2().reference().list().await.unwrap();
assert_eq!(references.data.len(), 1);
}