# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "#usage_report" do let(:api_key) { "sk-ant-api03-test" } let(:oauth_key) { "sk-ant-oat-test-token" } let(:base_url) { "https://api.anthropic.com" } let(:usage_url) { "#{base_url}/api/oauth/usage" } let(:profile_url) { "#{base_url}/api/oauth/profile" } before { WebMock.disable_net_connect! } after { WebMock.reset! } # ── Helpers ─────────────────────────────────────────────────────────────── def make_adapter(key: api_key, oauth: false) described_class.new( model: "claude-sonnet-4-6", api_key: key, base_url: base_url, is_oauth: oauth ) end def stub_usage(body, status: 200) stub_request(:get, usage_url) .to_return( status: status, body: JSON.generate(body), headers: { "Content-Type" => "application/json" } ) end # A realistic usage payload with all four buckets let(:full_payload) do { "five_hour" => { "utilization" => 45.0, "resets_at" => "2025-01-01T12:00:00Z" }, "seven_day" => { "utilization" => 92.0, "resets_at" => "2025-01-07T00:00:00Z" }, "seven_day_opus" => { "utilization" => 95.5, "resets_at" => "2025-01-07T00:00:00Z" }, "seven_day_sonnet" => { "utilization" => 100.0, "resets_at" => "2025-01-07T00:00:00Z" } } end # ── API-key mode returns nil immediately ────────────────────────────────── describe "API-key (non-OAuth) mode" do subject(:adapter) { make_adapter(key: api_key, oauth: false) } it "returns nil without making any HTTP call" do result = adapter.usage_report expect(result).to be_nil expect(WebMock).not_to have_requested(:get, usage_url) end end # ── OAuth mode — successful response ───────────────────────────────────── describe "OAuth mode — full payload" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do stub_usage(full_payload) stub_request(:get, profile_url).to_return( status: 200, body: JSON.generate({ "email" => "u@example.com", "account_id" => "acct_123" }), headers: { "Content-Type" => "application/json" } ) end it "returns a UsageReport" do expect(adapter.usage_report).to be_a(Dispatch::Adapter::UsageReport) end it "sets provider to 'Anthropic (Claude)'" do expect(adapter.usage_report.provider).to eq("Anthropic (Claude)") end it "returns 4 limits (one per bucket)" do expect(adapter.usage_report.limits.size).to eq(4) end it "all limits are UsageLimitEntry objects" do expect(adapter.usage_report.limits).to all(be_a(Dispatch::Adapter::UsageLimitEntry)) end it "five_hour entry has :ok status (45%)" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry).not_to be_nil expect(entry.status).to eq(:ok) end it "seven_day entry has :warning status (92%)" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } expect(entry).not_to be_nil expect(entry.status).to eq(:warning) end it "seven_day_opus entry has :warning status (95.5%)" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } expect(entry).not_to be_nil expect(entry.status).to eq(:warning) end it "seven_day_sonnet entry has :exhausted status (100%)" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:sonnet" } expect(entry).not_to be_nil expect(entry.status).to eq(:exhausted) end it "amount.used reflects utilization" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.amount.used).to be_within(0.001).of(45.0) end it "amount.remaining = 100 - used" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.amount.remaining).to be_within(0.001).of(55.0) end it "amount.unit is :percent" do adapter.usage_report.limits.each do |entry| expect(entry.amount.unit).to eq(:percent) end end it "amount.limit is 100" do adapter.usage_report.limits.each do |entry| expect(entry.amount.limit).to eq(100) end end it "window duration_ms for 5h is 18_000_000" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.window.duration_ms).to eq(18_000_000) end it "window duration_ms for 7d is 604_800_000" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d" } expect(entry.window.duration_ms).to eq(604_800_000) end it "window.resets_at is a Time for five_hour" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.window.resets_at).to be_a(Time) end it "five_hour scope has shared: true" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.scope[:shared]).to be true end it "seven_day_opus scope has shared: false" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } expect(entry.scope[:shared]).to be false end it "seven_day_opus scope has tier: 'opus'" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:opus" } expect(entry.scope[:tier]).to eq("opus") end it "five_hour label is 'Claude 5 Hour'" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:5h" } expect(entry.label).to eq("Claude 5 Hour") end it "seven_day_sonnet label is 'Claude 7 Day (Sonnet)'" do entry = adapter.usage_report.limits.find { |e| e.id == "anthropic:7d:sonnet" } expect(entry.label).to eq("Claude 7 Day (Sonnet)") end end # ── Status thresholds ───────────────────────────────────────────────────── describe "status thresholds" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do stub_request(:get, profile_url).to_return( status: 200, body: JSON.generate({ "email" => "u@example.com", "account_id" => "acct_123" }), headers: { "Content-Type" => "application/json" } ) end [ [89.9, :ok], [90.0, :warning], [99.9, :warning], [100.0, :exhausted], [105.0, :exhausted] ].each do |utilization, expected_status| it "#{utilization}% → #{expected_status}" do stub_usage({ "five_hour" => { "utilization" => utilization, "resets_at" => nil } }) entry = adapter.usage_report.limits.first expect(entry.status).to eq(expected_status) end end end # ── Empty payload returns nil ───────────────────────────────────────────── describe "empty payload (no recognised buckets)" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before { stub_usage({ "other_field" => "value" }) } it "returns nil when no recognised buckets are present" do expect(adapter.usage_report).to be_nil end end # ── Profile fetch for missing metadata ──────────────────────────────────── describe "profile fetch for email/account_id" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do stub_usage({ "five_hour" => { "utilization" => 50.0 } }) stub_request(:get, profile_url) .to_return( status: 200, body: JSON.generate({ "email" => "user@example.com", "account_id" => "acct_123" }), headers: { "Content-Type" => "application/json" } ) end it "fetches profile to populate email" do report = adapter.usage_report expect(report.metadata[:email]).to eq("user@example.com") end it "fetches profile to populate account_id" do report = adapter.usage_report expect(report.metadata[:account_id]).to eq("acct_123") end end describe "email/account_id already in usage payload" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do payload = full_payload.merge("email" => "me@example.com", "account_id" => "acct_456") stub_usage(payload) end it "uses email from usage payload without calling profile" do report = adapter.usage_report expect(report.metadata[:email]).to eq("me@example.com") expect(WebMock).not_to have_requested(:get, profile_url) end end # ── Failure returns nil ─────────────────────────────────────────────────── describe "network failure returns nil" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do stub_request(:get, usage_url).to_raise(Errno::ECONNREFUSED) end it "returns nil without raising" do expect(adapter.usage_report).to be_nil end end describe "HTTP 500 returns nil after retries" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do # Suppress sleep in UsageClient retry loop (it's a module_function, # sleep is called via Kernel; stub at the UsageClient module level) allow(Dispatch::Adapter::Claude::UsageClient).to receive(:sleep) stub_request(:get, usage_url) .to_return( status: 500, body: JSON.generate({ "error" => { "message" => "oops" } }), headers: { "Content-Type" => "application/json" } ) end it "returns nil after exhausting retries" do expect(adapter.usage_report).to be_nil end end describe "HTTP 401 returns nil immediately" do subject(:adapter) { make_adapter(key: oauth_key, oauth: true) } before do stub_request(:get, usage_url) .to_return( status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" } ) end it "returns nil on auth error without raising" do expect(adapter.usage_report).to be_nil end end end