# 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