321 lines
9.4 KiB
Ruby
321 lines
9.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "minitest/autorun"
|
|
require "webmock/minitest"
|
|
require "json"
|
|
require "tempfile"
|
|
require "stringio"
|
|
|
|
require "clawdforge"
|
|
|
|
BASE_URL = "http://forge.test:8800"
|
|
TOKEN = "cf_test_token_xxxxxxxx"
|
|
|
|
def build_client(**overrides)
|
|
Clawdforge::Client.new(
|
|
base_url: BASE_URL,
|
|
token: TOKEN,
|
|
default_timeout: 60,
|
|
**overrides,
|
|
)
|
|
end
|
|
|
|
class HealthzTest < Minitest::Test
|
|
def test_healthz_returns_parsed_json
|
|
stub_request(:get, "#{BASE_URL}/healthz")
|
|
.with(headers: { "Authorization" => "Bearer #{TOKEN}" })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"ok" => true, "claude_present" => true, "claude_version" => "1.2.3",
|
|
),
|
|
)
|
|
|
|
out = build_client.healthz
|
|
|
|
assert_equal true, out["ok"]
|
|
assert_equal "1.2.3", out["claude_version"]
|
|
end
|
|
end
|
|
|
|
class RunTest < Minitest::Test
|
|
def test_run_success_with_json_result
|
|
stub_request(:post, "#{BASE_URL}/run").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"ok" => true,
|
|
"result" => { "hello" => "world" },
|
|
"duration_ms" => 1234,
|
|
"stop_reason" => "end_turn",
|
|
),
|
|
)
|
|
|
|
r = build_client.run(prompt: 'Reply with JSON: {"hello": "world"}')
|
|
|
|
assert_kind_of Clawdforge::RunResult, r
|
|
assert_equal true, r.ok
|
|
assert_equal({ "hello" => "world" }, r.result)
|
|
assert_equal 1234, r.duration_ms
|
|
assert_equal "end_turn", r.stop_reason
|
|
end
|
|
|
|
def test_run_success_with_string_result
|
|
stub_request(:post, "#{BASE_URL}/run").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"ok" => true, "result" => "plain text reply",
|
|
"duration_ms" => 800, "stop_reason" => "end_turn",
|
|
),
|
|
)
|
|
|
|
r = build_client.run(prompt: "hello")
|
|
assert_equal "plain text reply", r.result
|
|
end
|
|
|
|
def test_run_sends_full_body_and_auth
|
|
captured = {}
|
|
stub_request(:post, "#{BASE_URL}/run").with do |req|
|
|
captured[:auth] = req.headers["Authorization"]
|
|
captured[:body] = JSON.parse(req.body)
|
|
true
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("ok" => true, "result" => {}, "duration_ms" => 1, "stop_reason" => "end_turn"),
|
|
)
|
|
|
|
build_client.run(
|
|
prompt: "hi", model: "opus", system: "be terse",
|
|
files: ["ff_abc"], timeout_secs: 42,
|
|
)
|
|
|
|
assert_equal "Bearer #{TOKEN}", captured[:auth]
|
|
assert_equal(
|
|
{
|
|
"prompt" => "hi", "model" => "opus", "system" => "be terse",
|
|
"files" => ["ff_abc"], "timeout_secs" => 42,
|
|
},
|
|
captured[:body],
|
|
)
|
|
end
|
|
|
|
def test_run_uses_default_model_and_omits_optional_fields
|
|
captured = {}
|
|
stub_request(:post, "#{BASE_URL}/run").with do |req|
|
|
captured[:body] = JSON.parse(req.body)
|
|
true
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("ok" => true, "result" => "x", "duration_ms" => 1, "stop_reason" => "end_turn"),
|
|
)
|
|
|
|
build_client.run(prompt: "hi")
|
|
|
|
assert_equal "sonnet", captured[:body]["model"]
|
|
refute captured[:body].key?("system")
|
|
refute captured[:body].key?("files")
|
|
refute captured[:body].key?("timeout_secs")
|
|
end
|
|
|
|
def test_run_502_raises_api_error_with_body
|
|
stub_request(:post, "#{BASE_URL}/run").to_return(
|
|
status: 502,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"ok" => false, "error" => "subprocess timed out",
|
|
"stderr" => "...", "duration_ms" => 60_000, "stop_reason" => "timeout",
|
|
),
|
|
)
|
|
|
|
err = assert_raises(Clawdforge::APIError) do
|
|
build_client.run(prompt: "hi", timeout_secs: 60)
|
|
end
|
|
assert_equal 502, err.status
|
|
assert_kind_of Hash, err.body
|
|
assert_equal "timeout", err.body["stop_reason"]
|
|
assert_match(/subprocess timed out/, err.message)
|
|
end
|
|
|
|
def test_run_401_raises_auth_error
|
|
stub_request(:post, "#{BASE_URL}/run").to_return(
|
|
status: 401,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("detail" => "missing bearer"),
|
|
)
|
|
|
|
err = assert_raises(Clawdforge::AuthError) { build_client.run(prompt: "hi") }
|
|
assert_equal 401, err.status
|
|
assert_kind_of Clawdforge::APIError, err
|
|
assert_kind_of Clawdforge::Error, err
|
|
end
|
|
|
|
def test_run_transport_error_when_unreachable
|
|
stub_request(:post, "#{BASE_URL}/run").to_raise(SocketError.new("getaddrinfo: nope"))
|
|
|
|
assert_raises(Clawdforge::TransportError) { build_client.run(prompt: "hi") }
|
|
end
|
|
|
|
def test_run_empty_prompt_rejected_locally
|
|
assert_raises(ArgumentError) { build_client.run(prompt: "") }
|
|
end
|
|
end
|
|
|
|
class FilesTest < Minitest::Test
|
|
def test_upload_file_from_path
|
|
stub_request(:post, "#{BASE_URL}/files")
|
|
.with(headers: { "Content-Type" => %r{\Amultipart/form-data; boundary=} })
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("file_token" => "ff_abc123", "ttl_secs" => 3600, "size" => 11),
|
|
)
|
|
|
|
Tempfile.create(["upload", ".txt"]) do |tf|
|
|
tf.binmode
|
|
tf.write("hello world")
|
|
tf.flush
|
|
|
|
ft = build_client.upload_file(tf.path, ttl_secs: 3600)
|
|
|
|
assert_kind_of Clawdforge::FileToken, ft
|
|
assert_equal "ff_abc123", ft.file_token
|
|
assert_equal 3600, ft.ttl_secs
|
|
assert_equal 11, ft.size
|
|
end
|
|
end
|
|
|
|
def test_upload_file_sends_multipart_with_ttl_and_file_parts
|
|
captured = {}
|
|
stub_request(:post, "#{BASE_URL}/files").with do |req|
|
|
captured[:content_type] = req.headers["Content-Type"]
|
|
captured[:body] = req.body
|
|
true
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("file_token" => "ff_xyz", "ttl_secs" => 60, "size" => 5),
|
|
)
|
|
|
|
Tempfile.create(["snippet", ".bin"]) do |tf|
|
|
tf.binmode
|
|
tf.write("hello")
|
|
tf.flush
|
|
|
|
build_client.upload_file(tf.path, ttl_secs: 60, filename: "snippet.txt")
|
|
end
|
|
|
|
assert_match(%r{\Amultipart/form-data; boundary=}, captured[:content_type])
|
|
assert_match(/name="ttl_secs"/, captured[:body])
|
|
assert_match(/name="file"; filename="snippet\.txt"/, captured[:body])
|
|
assert_match(/hello/, captured[:body])
|
|
end
|
|
|
|
def test_upload_file_400_raises_api_error
|
|
stub_request(:post, "#{BASE_URL}/files").to_return(
|
|
status: 400,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("detail" => "ttl_secs out of range (60..86400)"),
|
|
)
|
|
|
|
Tempfile.create(["bad", ".bin"]) do |tf|
|
|
tf.binmode
|
|
tf.write("x")
|
|
tf.flush
|
|
|
|
err = assert_raises(Clawdforge::APIError) do
|
|
build_client.upload_file(tf.path, ttl_secs: 10)
|
|
end
|
|
assert_equal 400, err.status
|
|
end
|
|
end
|
|
end
|
|
|
|
class AdminTokensTest < Minitest::Test
|
|
def test_create_token
|
|
stub_request(:post, "#{BASE_URL}/admin/tokens").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"name" => "cauldron",
|
|
"token" => "cf_brandnew_xxx",
|
|
"ip_cidrs" => ["172.24.0.0/16"],
|
|
),
|
|
)
|
|
|
|
t = build_client.create_token("cauldron", ip_cidrs: ["172.24.0.0/16"])
|
|
|
|
assert_kind_of Clawdforge::AppToken, t
|
|
assert_equal "cauldron", t.name
|
|
assert_equal "cf_brandnew_xxx", t.token
|
|
assert_equal ["172.24.0.0/16"], t.ip_cidrs
|
|
end
|
|
|
|
def test_list_tokens_normalizes_csv_ip_cidrs
|
|
stub_request(:get, "#{BASE_URL}/admin/tokens").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
"tokens" => [
|
|
{
|
|
"name" => "cauldron", "ip_cidrs" => "172.24.0.0/16",
|
|
"created_at" => 100, "last_used" => 200, "enabled" => 1,
|
|
},
|
|
{
|
|
"name" => "petalparse", "ip_cidrs" => "",
|
|
"created_at" => 50, "last_used" => nil, "enabled" => 0,
|
|
},
|
|
],
|
|
),
|
|
)
|
|
|
|
toks = build_client.list_tokens
|
|
|
|
assert_equal 2, toks.length
|
|
assert_equal "cauldron", toks[0].name
|
|
assert_equal ["172.24.0.0/16"], toks[0].ip_cidrs
|
|
assert_equal true, toks[0].enabled
|
|
assert_nil toks[0].token
|
|
assert_equal [], toks[1].ip_cidrs
|
|
assert_equal false, toks[1].enabled
|
|
end
|
|
|
|
def test_revoke_token_ok
|
|
stub_request(:delete, "#{BASE_URL}/admin/tokens/cauldron").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("ok" => true),
|
|
)
|
|
|
|
assert_equal true, build_client.revoke_token("cauldron")
|
|
end
|
|
|
|
def test_revoke_token_404
|
|
stub_request(:delete, "#{BASE_URL}/admin/tokens/nosuch").to_return(
|
|
status: 404,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate("detail" => "no such token"),
|
|
)
|
|
|
|
err = assert_raises(Clawdforge::APIError) { build_client.revoke_token("nosuch") }
|
|
assert_equal 404, err.status
|
|
end
|
|
end
|
|
|
|
class ClientConstructionTest < Minitest::Test
|
|
def test_requires_base_url
|
|
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "", token: "cf_x") }
|
|
end
|
|
|
|
def test_requires_token
|
|
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "http://x", token: "") }
|
|
end
|
|
|
|
def test_strips_trailing_slash
|
|
c = Clawdforge::Client.new(base_url: "http://x:8800/", token: "cf_x")
|
|
assert_equal "http://x:8800", c.base_url
|
|
end
|
|
end
|