From 05425a89e3d26b72f964ac3a1ce0167b9257c389 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:55:53 +0000 Subject: [PATCH 1/5] test: align spec suite with the JavaScript SDK baseline Bring the Ruby spec suite in line with the scope and strategy used by seamapi/javascript-http: specs are organized by SDK concern, exercise the SDK against fake-seam-connect, and assert against seeded records rather than hand-written stubs. Fixture: - Add FakeSeamConnect, which starts the fake on an unused port, polls /health until it is ready, reads the seed, and stops the server after the example. Examples opt in with `:fake`. - Start the fake directly from node_modules/.bin rather than through `npm run start`, so the spawned pid is the server itself and no process group juggling is needed to stop it. - Replace spec/support/helpers.rb, whose stub_seam_request helper encouraged stubbing over exercising the SDK. Scope: - Drop spec/clients, which covered generated route methods rather than SDK behavior. The JavaScript SDK has no equivalent specs and reaches its routes only as a means of exercising the client. - Drop the overlapping request specs and the mocked action attempt resource spec, folding what they covered into http_error_spec and wait_for_action_attempt_spec against the fake. - Exclude generated routes and resources from coverage, so the report stops rewarding specs for generated code. - Fix the misspelled wait_for_action_attepmt_spec filename. Coverage: - Convert the api key, personal access token, env, http error, wait for action attempt, faraday options, and multi workspace specs to the fake. - Add serialization, headers, retry, and defaults specs. - Cover invalid input validation messages, paginator argument checks, and the personal access token option checks. WebMock is kept for the two cases the fake cannot serve, asserting the request the SDK sends and counting retry attempts, which is the role nock plays in the JavaScript SDK. Two specs are marked pending, both bugs the previous stubs concealed: - The client option cannot be used on its own. initialize parses the auth options before it considers the client, so it still demands an api_key or personal_access_token. - wait_for_action_attempt set to a hash on the client never waits. SingleWorkspace wraps its defaults in a DeepHashAccessor, so the value no longer satisfies the is_a?(Hash) check in decide_and_wait. The old spec passed only because its stub returned a successful action attempt and never had to wait. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DzapiU8A9NMdyoTybL9xB --- spec/clients/access_codes_spec.rb | 178 ------------------ spec/clients/action_attempts_spec.rb | 22 --- spec/clients/connect_webviews_spec.rb | 73 ------- spec/clients/connected_accounts_spec.rb | 81 -------- spec/clients/devices_spec.rb | 159 ---------------- spec/clients/events_spec.rb | 40 ---- spec/clients/locks_spec.rb | 87 --------- spec/clients/unmanaged_access_codes_spec.rb | 92 --------- spec/clients/unmanaged_devices_spec.rb | 75 -------- spec/clients/workspaces_spec.rb | 56 ------ spec/integration/basic_usage_spec.rb | 18 -- spec/paginator_spec.rb | 22 +-- spec/request_spec.rb | 59 ------ spec/resources/action_attempt_spec.rb | 81 -------- spec/seam_client/api_key_spec.rb | 42 +++-- .../client_multi_workspace_spec.rb | 82 ++++---- spec/seam_client/defaults_spec.rb | 34 ++++ spec/seam_client/env_spec.rb | 112 ++++++----- spec/seam_client/faraday_options_spec.rb | 86 ++++++--- .../seam_client/faraday_retry_options_spec.rb | 41 ---- spec/seam_client/headers_spec.rb | 58 ++++++ spec/seam_client/http_error_spec.rb | 78 ++++---- spec/seam_client/init_seam_spec.rb | 13 -- .../seam_client/personal_access_token_spec.rb | 61 +++++- spec/seam_client/request_spec.rb | 93 --------- spec/seam_client/retry_spec.rb | 86 +++++++++ .../wait_for_action_attempt_spec.rb | 155 +++++++++++++++ .../wait_for_action_attepmt_spec.rb | 176 ----------------- spec/serialization_spec.rb | 41 ++++ spec/spec_helper.rb | 19 +- spec/support/fake_seam_connect.rb | 133 +++++++++++++ spec/support/helpers.rb | 106 ----------- 32 files changed, 818 insertions(+), 1641 deletions(-) delete mode 100644 spec/clients/access_codes_spec.rb delete mode 100644 spec/clients/action_attempts_spec.rb delete mode 100644 spec/clients/connect_webviews_spec.rb delete mode 100644 spec/clients/connected_accounts_spec.rb delete mode 100644 spec/clients/devices_spec.rb delete mode 100644 spec/clients/events_spec.rb delete mode 100644 spec/clients/locks_spec.rb delete mode 100644 spec/clients/unmanaged_access_codes_spec.rb delete mode 100644 spec/clients/unmanaged_devices_spec.rb delete mode 100644 spec/clients/workspaces_spec.rb delete mode 100644 spec/integration/basic_usage_spec.rb delete mode 100644 spec/request_spec.rb delete mode 100644 spec/resources/action_attempt_spec.rb create mode 100644 spec/seam_client/defaults_spec.rb delete mode 100644 spec/seam_client/faraday_retry_options_spec.rb create mode 100644 spec/seam_client/headers_spec.rb delete mode 100644 spec/seam_client/init_seam_spec.rb delete mode 100644 spec/seam_client/request_spec.rb create mode 100644 spec/seam_client/retry_spec.rb create mode 100644 spec/seam_client/wait_for_action_attempt_spec.rb delete mode 100644 spec/seam_client/wait_for_action_attepmt_spec.rb create mode 100644 spec/serialization_spec.rb create mode 100644 spec/support/fake_seam_connect.rb delete mode 100644 spec/support/helpers.rb diff --git a/spec/clients/access_codes_spec.rb b/spec/clients/access_codes_spec.rb deleted file mode 100644 index b29b9bf5..00000000 --- a/spec/clients/access_codes_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::AccessCodes do - let(:seam) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:access_code_id) { "123" } - let(:access_code_hash) { {access_code_id: access_code_id} } - - context "'device_id' param" do - let(:device_id) { "device_id_1234" } - - before do - stub_seam_request(:post, "/access_codes/list", - {access_codes: [access_code_hash]}).with do |req| - req.body == {device_id: device_id}.to_json - end - end - - let(:access_codes) { seam.access_codes.list(device_id: device_id) } - - it "returns a list of Access codes" do - expect(access_codes).to be_a(Array) - expect(access_codes.first).to be_a(Seam::Resources::AccessCode) - expect(access_codes.first.access_code_id).to be_a(String) - end - - it "has client set" do - expect(seam.client).not_to be_nil - end - it "initializes access_codes" do - expect(seam.access_codes).not_to be_nil - end - end - - context "'access_code_ids' param" do - before do - stub_seam_request(:post, "/access_codes/list", - {access_codes: [access_code_hash]}).with do |req| - req.body == {access_code_ids: [access_code_id]}.to_json - end - end - - let(:access_codes) { seam.access_codes.list(access_code_ids: [access_code_id]) } - - it "returns a list of Access codes" do - expect(access_codes).to be_a(Array) - expect(access_codes.first).to be_a(Seam::Resources::AccessCode) - expect(access_codes.first.access_code_id).to be_a(String) - end - end - end - - describe "#get" do - let(:access_code_id) { "access_code_id_1234" } - let(:access_code_hash) { {access_code_id: access_code_id} } - let(:delay_in_setting_warning) do - {warning_code: "delay_in_setting_on_device", message: "Delay in setting access code"} - end - let(:failed_to_set_error) { {error_code: "failed_to_set_on_device", message: "Failed to set access code"} } - - before do - stub_seam_request( - :post, "/access_codes/get", {access_code: access_code_hash.merge( - errors: [failed_to_set_error], - warnings: [delay_in_setting_warning] - )} - ).with { |req| req.body == {access_code_id: access_code_id}.to_json } - end - - let(:result) { seam.access_codes.get(access_code_id: access_code_id) } - - it "returns an Access Code" do - expect(result).to be_a(Seam::Resources::AccessCode) - end - - it "returns access code errors" do - expect(result.errors.first.error_code).to eq("failed_to_set_on_device") - end - - it "returns access code warnings" do - expect(result.warnings.first.warning_code).to eq("delay_in_setting_on_device") - end - end - - describe "#create" do - let(:access_code_hash) { {device_id: "1234", name: "A C", code: 1234} } - - before do - stub_seam_request( - :post, "/access_codes/create", {access_code: access_code_hash} - ) - end - - let(:result) { seam.access_codes.create(**access_code_hash) } - - it "returns an Access Code" do - expect(result).to be_a(Seam::Resources::AccessCode) - end - end - - describe "#delete" do - let(:access_code_id) { "access_code_1234" } - let(:action_attempt_hash) { {action_attempt_id: "1234", status: "pending"} } - - before do - stub_seam_request( - :post, "/access_codes/delete", {action_attempt: action_attempt_hash} - ).with do |req| - req.body == {access_code_id: access_code_id}.to_json - end - - stub_seam_request( - :post, - "/action_attempts/get", - { - action_attempt: { - status: "success" - } - } - ).with { |req| req.body == {action_attempt_id: action_attempt_hash[:action_attempt_id]}.to_json } - end - - let(:result) { seam.access_codes.delete(access_code_id: access_code_id) } - - it "returns an Access Code" do - expect(result).to be_a(NilClass) - end - end - - describe "#update" do - let(:access_code_id) { "access_code_1234" } - let(:action_attempt_hash) { {action_attempt_id: "1234", status: "pending"} } - - before do - stub_seam_request( - :post, "/access_codes/update", {action_attempt: action_attempt_hash} - ).with do |req| - req.body == {access_code_id: access_code_id, type: "ongoing"}.to_json - end - - stub_seam_request( - :post, - "/action_attempts/get", - { - action_attempt: { - status: "success" - } - } - ).with { |req| req.body == {action_attempt_id: action_attempt_hash[:action_attempt_id]}.to_json } - end - - let(:result) { seam.access_codes.update(access_code_id: access_code_id, type: "ongoing") } - - it "returns an Access Code" do - expect(result).to be_a(NilClass) - end - end - - describe "#pull_backup_access_code" do - let(:access_code_id) { "access_code_id_1234" } - let(:access_code_hash) { {access_code_id: access_code_id, is_backup: true} } - - before do - stub_seam_request( - :post, "/access_codes/pull_backup_access_code", {access_code: access_code_hash} - ).with do |req| - req.body == {access_code_id: access_code_id}.to_json - end - end - - let(:result) { seam.access_codes.pull_backup_access_code(access_code_id: access_code_id) } - - it "returns an backup Access Code" do - expect(result).to be_a(Seam::Resources::AccessCode) - end - end -end diff --git a/spec/clients/action_attempts_spec.rb b/spec/clients/action_attempts_spec.rb deleted file mode 100644 index e2411ad2..00000000 --- a/spec/clients/action_attempts_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::AccessCodes do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#get" do - let(:action_attempt_id) { "action_attempt_id_1234" } - let(:action_attempt_hash) { {action_attempt_id: action_attempt_id} } - - before do - stub_seam_request( - :post, "/action_attempts/get", {action_attempt: action_attempt_hash} - ).with { |req| req.body == {action_attempt_id: action_attempt_id}.to_json } - end - - let(:result) { client.action_attempts.get(action_attempt_id: action_attempt_id) } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::ActionAttempt) - end - end -end diff --git a/spec/clients/connect_webviews_spec.rb b/spec/clients/connect_webviews_spec.rb deleted file mode 100644 index e04eff8a..00000000 --- a/spec/clients/connect_webviews_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::ConnectWebviews do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:connect_webview_hash) { {connect_webview_id: "123"} } - # let(:device_id) { "device_id_1234" } - - before do - stub_seam_request(:post, "/connect_webviews/list", {connect_webviews: [connect_webview_hash]}) - end - - let(:connect_webviews) { client.connect_webviews.list } - - it "returns a list of Devices" do - expect(connect_webviews).to be_a(Array) - expect(connect_webviews.first).to be_a(Seam::Resources::ConnectWebview) - expect(connect_webviews.first.connect_webview_id).to be_a(String) - end - end - - describe "#get" do - let(:connect_webview_id) { "connect_webview_id_1234" } - let(:connect_webview_hash) { {connect_webview_id: connect_webview_id} } - - before do - stub_seam_request( - :post, "/connect_webviews/get", {connect_webview: connect_webview_hash} - ).with { |req| req.body == {connect_webview_id: connect_webview_id}.to_json } - end - - let(:result) { client.connect_webviews.get(connect_webview_id: connect_webview_id) } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::ConnectWebview) - end - end - - describe "#create" do - let(:accepted_providers) { %w[facebook google] } - let(:custom_redirect_url) { "http://localhost:3000/success" } - let(:custom_redirect_failure_url) { "http://localhost:3000/failure" } - let(:automatically_manage_new_devices) { false } - let(:connect_webview_hash) { {connect_webview_id: "123"} } - - before do - stub_seam_request( - :post, "/connect_webviews/create", {connect_webview: connect_webview_hash} - ).with do |req| - req.body == { - accepted_providers: accepted_providers, - automatically_manage_new_devices: automatically_manage_new_devices, - custom_redirect_failure_url: custom_redirect_failure_url, - custom_redirect_url: custom_redirect_url - }.to_json - end - end - - let(:result) do - client.connect_webviews.create( - accepted_providers: accepted_providers, - automatically_manage_new_devices: automatically_manage_new_devices, - custom_redirect_failure_url: custom_redirect_failure_url, - custom_redirect_url: custom_redirect_url - ) - end - - it "returns a ConnectWebview" do - expect(result).to be_a(Seam::Resources::ConnectWebview) - end - end -end diff --git a/spec/clients/connected_accounts_spec.rb b/spec/clients/connected_accounts_spec.rb deleted file mode 100644 index 129e1db0..00000000 --- a/spec/clients/connected_accounts_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::ConnectedAccounts do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - let(:connected_account_id) { "connected_account_id_1234" } - - describe "#get" do - let(:connected_account_hash) { {connected_account_id: connected_account_id} } - - context "with connected_account_id" do - before do - stub_seam_request( - :post, "/connected_accounts/get", {connected_account: connected_account_hash} - ).with { |req| req.body == {connected_account_id: connected_account_id}.to_json } - end - - let(:result) { client.connected_accounts.get(connected_account_id: connected_account_id) } - - it "returns a ConnectedAccount" do - expect(result).to be_a(Seam::Resources::ConnectedAccount) - end - end - - context "with email" do - let(:email) { "jane@example.com" } - - before do - stub_seam_request( - :post, "/connected_accounts/get", {connected_account: connected_account_hash} - ).with { |req| req.body == {email: email}.to_json } - end - - let(:result) { client.connected_accounts.get(email: email) } - - it "returns a ConnectedAccount" do - expect(result).to be_a(Seam::Resources::ConnectedAccount) - end - end - - context "with errors and warnings" do - let(:account_disconnected_error) { {error_code: "account_disconnected", message: "Account was disconnected"} } - let(:limit_reached_warning) { {warning_code: "limit_reached", message: "Account reached its limit"} } - - before do - stub_seam_request( - :post, "/connected_accounts/get", {connected_account: connected_account_hash.merge( - errors: [account_disconnected_error], - warnings: [limit_reached_warning] - )} - ).with { |req| req.body == {connected_account_id: connected_account_id}.to_json } - end - - let(:result) { client.connected_accounts.get(connected_account_id: connected_account_id) } - - it "returns errors on connected account" do - expect(result.errors.first.error_code).to eq("account_disconnected") - end - - it "returns warnings on connected account" do - expect(result.warnings.first.warning_code).to eq("limit_reached") - end - end - end - - describe "#list" do - let(:connected_account_hash) { {connected_account_id: connected_account_id} } - - before do - stub_seam_request( - :post, "/connected_accounts/list", {connected_accounts: [connected_account_hash]} - ) - end - - let(:result) { client.connected_accounts.list } - - it "returns a ConnectedAccount Array" do - expect(result).to be_a(Array) - expect(result.first).to be_a(Seam::Resources::ConnectedAccount) - end - end -end diff --git a/spec/clients/devices_spec.rb b/spec/clients/devices_spec.rb deleted file mode 100644 index 07e2ac5f..00000000 --- a/spec/clients/devices_spec.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::Devices do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:device_hash) { {device_id: "123"} } - - before do - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - end - - let(:devices) { client.devices.list } - - it "returns a list of Devices" do - expect(devices).to be_a(Array) - expect(devices.first).to be_a(Seam::Resources::Device) - expect(devices.first.device_id).to be_a(String) - end - end - - describe "#get" do - context "'device_id' param'" do - let(:device_id) { "device_id_1234" } - let(:device_hash) { {device_id: device_id, properties: {manufacturer: "august"}} } - - before do - stub_seam_request(:post, "/devices/get", {device: device_hash}).with do |req| - req.body == {device_id: device_id}.to_json - end - end - - let(:result) { client.devices.get(device_id: device_id) } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::Device) - expect(result.properties.manufacturer).to eq("august") - end - end - - context "'name' param'" do - let(:name) { "name_1234" } - let(:device_hash) { {name: name} } - - before do - stub_seam_request(:post, "/devices/get", {device: device_hash}).with do |req| - req.body == {name: name}.to_json - end - end - - let(:result) { client.devices.get(name: name) } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::Device) - end - end - end - - describe "#get with errors" do - let(:device_id) { "device_id_1234" } - let(:device_hash) { {device_id: device_id} } - let(:device_removed_error) { {error_code: "device_removed", message: "Device was removed"} } - let(:device_privacy_warning) { {warning_code: "privacy_mode", message: "Device is in privacy mode"} } - - before do - stub_seam_request(:post, "/devices/get", { - device: device_hash.merge( - errors: [device_removed_error], - warnings: [device_privacy_warning] - ) - }).with { |req| req.body == {device_id: device_id}.to_json } - end - - let(:result) { client.devices.get(device_id: device_id) } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::Device) - end - - it "returns device errors" do - expect(result.errors.first.error_code).to eq("device_removed") - end - - it "returns device warnings" do - expect(result.warnings.first.warning_code).to eq("privacy_mode") - end - end - - let(:device_provider_hash) do - { - device_provider_name: "august", - display_name: "August", - provider_categories: ["stable"] - } - end - let(:stable_device_provider_hash) do - { - device_provider_name: "akuvox", - display_name: "Akuvox", - provider_categories: [] - } - end - - describe "#list_device_providers" do - before do - stub_seam_request(:post, "/devices/list_device_providers", - {device_providers: [device_provider_hash, stable_device_provider_hash]}) - end - - let(:device_providers) { client.devices.list_device_providers } - - it "returns a list of stable Device Providers" do - expect(device_providers).to be_a(Array) - expect(device_providers.length).to eq(2) - expect(device_providers.first).to be_a(Seam::Resources::DeviceProvider) - expect(device_providers.first.device_provider_name).to be_a(String) - expect(device_providers.first.display_name).to be_a(String) - expect(device_providers.first.provider_categories).to be_a(Array) - end - end - - describe "#list_device_providers (provider_category=stable)" do - before do - stub_seam_request(:post, "/devices/list_device_providers", - {device_providers: [stable_device_provider_hash]}) - .with { |req| req.body == {provider_category: "stable"}.to_json } - end - - let(:device_providers) { client.devices.list_device_providers(provider_category: "stable") } - - it "returns a list of stable Device Providers" do - expect(device_providers).to be_a(Array) - expect(device_providers.length).to eq(1) - - expect(device_providers.first).to be_a(Seam::Resources::DeviceProvider) - expect(device_providers.first.device_provider_name).to be_a(String) - expect(device_providers.first.display_name).to be_a(String) - expect(device_providers.first.provider_categories).to be_a(Array) - end - end - - describe "#update device" do - let(:device_id) { "device_id_1234" } - let(:name) { "New Device Name" } - - before do - stub_seam_request(:post, "/devices/update", nil) - .with do |req| - req.body == {device_id: device_id, name: name}.to_json - end - end - - let(:response) { client.devices.update(device_id: device_id, name: name) } - - it "returns success" do - expect(response).to be_a(NilClass) - end - end -end diff --git a/spec/clients/events_spec.rb b/spec/clients/events_spec.rb deleted file mode 100644 index d25d756a..00000000 --- a/spec/clients/events_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::Events do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:event_hash) { {event_id: "1234"} } - - before do - stub_seam_request(:post, "/events/list", {events: [event_hash]}).with do |req| - req.body == {since: "asd"}.to_json - end - end - - let(:events) { client.events.list(since: "asd") } - - it "returns a list of Events" do - expect(events).to be_a(Array) - expect(events.first).to be_a(Seam::Resources::SeamEvent) - expect(events.first.event_id).to be_a(String) - end - end - - describe "#get" do - let(:event_id) { "event_id_1234" } - let(:event_hash) { {event_id: event_id} } - - before do - stub_seam_request(:post, "/events/get", {event: event_hash}).with do |req| - req.body == {event_id: event_id}.to_json - end - end - - let(:result) { client.events.get(event_id: event_id) } - - it "returns an Event" do - expect(result).to be_a(Seam::Resources::SeamEvent) - end - end -end diff --git a/spec/clients/locks_spec.rb b/spec/clients/locks_spec.rb deleted file mode 100644 index dd49a502..00000000 --- a/spec/clients/locks_spec.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::Locks do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:locks_hash) { {device_id: "123"} } - - before do - stub_seam_request(:post, "/locks/list", {devices: [locks_hash]}) - end - - let(:devices) { client.locks.list } - - it "returns a list of Devices" do - expect(devices).to be_a(Array) - expect(devices.first).to be_a(Seam::Resources::Device) - expect(devices.first.device_id).to be_a(String) - end - end - - describe "#get" do - let(:device_id) { "device_id_1234" } - let(:locks_hash) { {device_id: device_id} } - - before do - stub_seam_request(:post, "/locks/get", {device: locks_hash}).with do |req| - req.body == {device_id: device_id}.to_json - end - end - - let(:lock) { client.locks.get(device_id: device_id) } - - it "returns a list of Devices" do - expect(lock).to be_a(Seam::Resources::Device) - expect(lock.device_id).to be_a(String) - end - end - - describe "#operations" do - let(:action_attempt_id) { "action_attempt_id_1234" } - let(:action_attempt_hash) do - {action_attempt_id: action_attempt_id, - action_type: "test", - status: "", - result: ""} - end - - let(:device_id) { "device_id_1234" } - - before do - stub_seam_request( - :post, - "/locks/#{endpoint}", - { - action_attempt: action_attempt_hash - } - ).with do |req| - req.body == {device_id: device_id}.to_json - end - end - - describe "#unlock" do - let(:endpoint) { "unlock_door" } - - describe "with a device_id" do - let(:result) { client.locks.unlock_door(device_id: device_id) } - - it "returns an action attempt" do - expect(result).to be_a(Seam::Resources::ActionAttempt) - end - end - end - - describe "#lock" do - let(:endpoint) { "lock_door" } - - describe "with a device_id" do - let(:result) { client.locks.lock_door(device_id: device_id) } - - it "returns an action attempt" do - expect(result).to be_a(Seam::Resources::ActionAttempt) - end - end - end - end -end diff --git a/spec/clients/unmanaged_access_codes_spec.rb b/spec/clients/unmanaged_access_codes_spec.rb deleted file mode 100644 index 98d11265..00000000 --- a/spec/clients/unmanaged_access_codes_spec.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::AccessCodesUnmanaged do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#get" do - let(:access_code_id) { "123" } - let(:unmanaged_access_code_hash) { {access_code_id: access_code_id} } - - before do - stub_seam_request( - :post, "/access_codes/unmanaged/get", {access_code: unmanaged_access_code_hash} - ).with { |req| req.body == {access_code_id: access_code_id}.to_json } - end - - let(:result) { client.unmanaged_access_codes.get(access_code_id: access_code_id) } - - it "returns an unmanaged Access Code" do - expect(result).to be_a(Seam::Resources::UnmanagedAccessCode) - end - end - - describe "#list" do - let(:device_id) { "456" } - let(:unmanaged_access_code_hash) { {access_code_id: "123", device_id: device_id} } - - before do - stub_seam_request(:post, "/access_codes/unmanaged/list", - {access_codes: [unmanaged_access_code_hash]}).with do |req| - req.body == {device_id: device_id}.to_json - end - end - - let(:unmanaged_access_codes) { client.unmanaged_access_codes.list(device_id: device_id) } - - it "returns a list of unmanaged Access Codes" do - expect(unmanaged_access_codes).to be_a(Array) - expect(unmanaged_access_codes.first).to be_a(Seam::Resources::UnmanagedAccessCode) - expect(unmanaged_access_codes.first.access_code_id).to be_a(String) - end - end - - describe "#convert_to_managed" do - let(:access_code_id) { "access_code_1234" } - let(:action_attempt_hash) { {action_attempt_id: "1234", status: "pending"} } - - before do - stub_seam_request( - :post, "/access_codes/unmanaged/convert_to_managed", {action_attempt: action_attempt_hash} - ).with do |req| - req.body == {access_code_id: access_code_id}.to_json - end - - stub_seam_request( - :post, - "/action_attempts/get", - nil - ).with { |req| req.body == {action_attempt_id: action_attempt_hash[:action_attempt_id]}.to_json } - end - - let(:result) { client.unmanaged_access_codes.convert_to_managed(access_code_id: access_code_id) } - - it "returns an Action Attempt" do - expect(result).to be_a(NilClass) - end - end - - describe "#delete" do - let(:access_code_id) { "access_code_5678" } - let(:action_attempt_hash) { {action_attempt_id: "5678", status: "pending"} } - - before do - stub_seam_request( - :post, "/access_codes/unmanaged/delete", {action_attempt: action_attempt_hash} - ).with do |req| - req.body == {access_code_id: access_code_id}.to_json - end - - stub_seam_request( - :post, - "/action_attempts/get", - nil - ).with { |req| req.body == {action_attempt_id: action_attempt_hash[:action_attempt_id]}.to_json } - end - - let(:result) { client.unmanaged_access_codes.delete(access_code_id: access_code_id) } - - it "returns an Action Attempt" do - expect(result).to be_a(NilClass) - end - end -end diff --git a/spec/clients/unmanaged_devices_spec.rb b/spec/clients/unmanaged_devices_spec.rb deleted file mode 100644 index 4b1f2471..00000000 --- a/spec/clients/unmanaged_devices_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::DevicesUnmanaged do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#get" do - context "'device_id' param" do - let(:device_id) { "123" } - let(:device_hash) { {device_id: device_id} } - - before do - stub_seam_request( - :post, "/devices/unmanaged/get", {device: device_hash} - ).with { |req| req.body == {device_id: device_id}.to_json } - end - - let(:result) { client.unmanaged_devices.get(device_id: device_id) } - - it "returns an unmanaged Device" do - expect(result).to be_a(Seam::Resources::UnmanagedDevice) - end - end - - context "'name' param" do - let(:name) { "name 123" } - let(:device_hash) { {name: name} } - - before do - stub_seam_request( - :post, "/devices/unmanaged/get", {device: device_hash} - ).with { |req| req.body == {name: name}.to_json } - end - - let(:result) { client.unmanaged_devices.get(name: name) } - - it "returns an unmanaged Device" do - expect(result).to be_a(Seam::Resources::UnmanagedDevice) - end - end - end - - describe "#list" do - let(:device_hash) { {device_id: "123"} } - - before do - stub_seam_request(:post, "/devices/unmanaged/list", {devices: [device_hash]}) - end - - let(:unmanaged_devices) { client.unmanaged_devices.list } - - it "returns a list of unmanaged Devices" do - expect(unmanaged_devices).to be_a(Array) - expect(unmanaged_devices.first).to be_a(Seam::Resources::UnmanagedDevice) - expect(unmanaged_devices.first.device_id).to be_a(String) - end - end - - describe "#update unmanaged device" do - let(:device_id) { "device_id_1234" } - let(:is_managed) { true } - - before do - stub_seam_request(:post, "/devices/unmanaged/update", nil) - .with do |req| - req.body == {device_id: device_id, is_managed: is_managed}.to_json - end - end - - let(:response) { client.unmanaged_devices.update(device_id: device_id, is_managed: is_managed) } - - it "returns success" do - expect(response).to be_a(NilClass) - end - end -end diff --git a/spec/clients/workspaces_spec.rb b/spec/clients/workspaces_spec.rb deleted file mode 100644 index 12c2d9b7..00000000 --- a/spec/clients/workspaces_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Clients::Workspaces do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "#list" do - let(:workspace_hash) { {workspace_id: "123"} } - - before do - stub_seam_request(:post, "/workspaces/list", {workspaces: [workspace_hash]}) - end - - let(:workspaces) { client.workspaces.list } - - it "returns a list of Workspaces" do - expect(workspaces).to be_a(Array) - expect(workspaces.first).to be_a(Seam::Resources::Workspace) - expect(workspaces.first.workspace_id).to be_a(String) - end - end - - describe "#get" do - let(:workspace_id) { "workspace_id_1234" } - let(:workspace_hash) { {workspace_id: workspace_id} } - - before do - stub_seam_request( - :post, "/workspaces/get", {workspace: workspace_hash} - ) - end - - let(:result) { client.workspaces.get } - - it "returns a Device" do - expect(result).to be_a(Seam::Resources::Workspace) - end - end - - describe "#reset_sandbox" do - let(:workspace_id) { "workspace_id_1234" } - let(:action_attempt_id) { "action_attempt_1234" } - let(:action_attempt_hash) { {action_attempt: action_attempt_id} } - - before do - stub_seam_request( - :post, "/workspaces/reset_sandbox", {action_attempt: action_attempt_hash} - ) - end - - let(:result) { client.workspaces.reset_sandbox } - - it "returns a Resets the Workspace" do - expect(result).to be_a(Seam::Resources::ActionAttempt) - end - end -end diff --git a/spec/integration/basic_usage_spec.rb b/spec/integration/basic_usage_spec.rb deleted file mode 100644 index d739b8f5..00000000 --- a/spec/integration/basic_usage_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "Basic SDK Usage" do - it "can list devices" do - with_fake_seam_connect do |_, endpoint, seed| - seam = Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) - devices = seam.devices.list - expect(devices).to be_a(Array) - expect(devices).not_to be_nil - expect(devices.length).to be > 0 - - device = devices.first - expect(device).to be_a(Seam::Resources::Device) - expect(device.device_id).to be_a(String) - expect(device.created_at).to be_a(Time) - end - end -end diff --git a/spec/paginator_spec.rb b/spec/paginator_spec.rb index 85f365c0..4a5cb66a 100644 --- a/spec/paginator_spec.rb +++ b/spec/paginator_spec.rb @@ -1,18 +1,8 @@ # frozen_string_literal: true -require "spec_helper" require "seam/paginator" -RSpec.describe Seam::Paginator do - around do |example| - with_fake_seam_connect do |seam, _endpoint, _seed| - @seam = seam - example.run - end - end - - let(:seam) { @seam } - +RSpec.describe Seam::Paginator, :fake do describe "#first_page" do it "fetches the first page of results and pagination info" do paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 2}) @@ -49,6 +39,7 @@ it "raises ArgumentError if next_page_cursor is nil or empty" do paginator = seam.create_paginator(seam.connected_accounts.method(:list), {limit: 2}) + expect { paginator.next_page(nil) }.to raise_error(ArgumentError, /nil or empty next_page_cursor/) expect { paginator.next_page("") }.to raise_error(ArgumentError, /nil or empty next_page_cursor/) end @@ -83,4 +74,13 @@ expect(collected_accounts.first).to be_a(Seam::Resources::ConnectedAccount) end end + + describe "#initialize" do + it "requires a Method and a Hash" do + expect { seam.create_paginator("not-a-method") }.to raise_error(ArgumentError, /must be a Method/) + expect do + seam.create_paginator(seam.connected_accounts.method(:list), "not-a-hash") + end.to raise_error(ArgumentError, /must be a Hash/) + end + end end diff --git a/spec/request_spec.rb b/spec/request_spec.rb deleted file mode 100644 index 63f7174a..00000000 --- a/spec/request_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Http do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - - describe "Exceptions" do - describe "400" do - let(:request_id) { "request_id_1234" } - let(:message) { "Some Error Message" } - let(:type) { "Some Error Type" } - let(:error) { {type: type, message: message} } - - before do - stub_seam_request( - :post, - "/devices/list", - {error: error}, - status: 400, - headers: {"seam-request-id" => request_id} - ) - end - - it "parses the error" do - expect { client.devices.list }.to raise_error do |error| - expect(error).to be_a(Seam::Http::ApiError) - expect(error.message).to eq(message) - expect(error.code).to eq(type) - expect(error.request_id).to eq(request_id) - end - end - end - - describe "409" do - let(:request_id) { "request_id_1234" } - let(:message) { "Some Error Message" } - let(:type) { "Some Error Type" } - let(:error) { {type: type, message: message} } - - before do - stub_seam_request( - :post, - "/devices/list", - {error: error}, - status: 409, - headers: {"seam-request-id" => request_id} - ) - end - - it "parses the error" do - expect { client.devices.list }.to raise_error do |error| - expect(error).to be_a(Seam::Http::ApiError) - expect(error.message).to eq(message) - expect(error.code).to eq(type) - expect(error.request_id).to eq(request_id) - end - end - end - end -end diff --git a/spec/resources/action_attempt_spec.rb b/spec/resources/action_attempt_spec.rb deleted file mode 100644 index e30f7b98..00000000 --- a/spec/resources/action_attempt_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require "seam/helpers/action_attempt" - -RSpec.describe Seam::Helpers::ActionAttempt do - let(:seam) { Seam.new(api_key: "seam_some_api_key") } - let(:action_attempt_id) { "action_attempt_id_1234" } - let(:finished_status) { "finished" } - let(:action_attempt_hash) do - { - action_attempt_id: action_attempt_id, - action_type: "some_action", - status: "pending", - result: {} - } - end - let(:action_attempt) { Seam::Resources::ActionAttempt.new(action_attempt_hash, seam) } - - describe ".decide_and_wait" do - context "when wait_for_action_attempt is true" do - it "calls wait_until_finished" do - expect(described_class).to receive(:wait_until_finished).with(action_attempt, seam) - described_class.decide_and_wait(action_attempt, seam, true) - end - end - - context "when wait_for_action_attempt is a hash" do - let(:wait_options) { {timeout: 10, polling_interval: 1} } - - it "calls wait_until_finished with options" do - expect(described_class).to receive(:wait_until_finished).with(action_attempt, seam, timeout: 10, - polling_interval: 1) - described_class.decide_and_wait(action_attempt, seam, wait_options) - end - end - end - - describe ".wait_until_finished" do - before do - stub_seam_request( - :get, - "/action_attempts/get", - {action_attempt: action_attempt_hash} - ).with(query: {action_attempt_id: action_attempt_id}) - .times(2) - .then - .to_return( - { - status: 200, - headers: {"Content-Type": "application/json"}, - body: { - action_attempt: action_attempt_hash.merge(status: finished_status) - }.to_json - } - ) - end - - let(:result) { described_class.wait_until_finished(action_attempt, seam.client) } - - it "returns an updated ActionAttempt" do - expect(result.status).to eq(finished_status) - expect(result).to be_a(Seam::Resources::ActionAttempt) - end - end - - describe ".update_action_attempt" do - let(:updated_action_attempt_hash) { action_attempt_hash.merge(status: "finished") } - before do - stub_seam_request( - :get, - "/action_attempts/get", - {action_attempt: updated_action_attempt_hash} - ).with(query: {action_attempt_id: action_attempt_id}) - end - - it "updates the ActionAttempt" do - updated_attempt = described_class.update_action_attempt(action_attempt, seam.client) - expect(updated_attempt.status).to eq("finished") - end - end -end diff --git a/spec/seam_client/api_key_spec.rb b/spec/seam_client/api_key_spec.rb index 668a61fd..016afb23 100644 --- a/spec/seam_client/api_key_spec.rb +++ b/spec/seam_client/api_key_spec.rb @@ -1,27 +1,41 @@ # frozen_string_literal: true -RSpec.describe Seam::Http do - let(:client) { Seam.new(api_key: "seam_some_api_key") } - let(:device_hash) { {device_id: "123"} } - - describe "#from_api_key" do +RSpec.describe Seam::Http, :fake do + describe ".from_api_key" do it "returns an instance authorized with the api key" do - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = client.devices.list - expect(devices.length).to be > 0 + seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint: endpoint) + + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) end end describe "#initialize" do it "returns an instance authorized with the api key" do - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = client.devices.list + seam = Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) + + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) + end + + it "returns resources with parsed attributes" do + devices = seam.devices.list + expect(devices.length).to be > 0 + + device = devices.first + expect(device).to be_a(Seam::Resources::Device) + expect(device.device_id).to be_a(String) + expect(device.created_at).to be_a(Time) end end - describe "#api_key_format" do - it "checks api key format" do + describe "api key format" do + it "rejects tokens that are not api keys" do expect do Seam.from_api_key("some-invalid-key-format") end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Unknown/) @@ -37,6 +51,10 @@ expect do Seam.from_api_key("seam_at") end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Access Token/) + + expect do + Seam.from_api_key("seam_pk_token") + end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Publishable Key/) end end end diff --git a/spec/seam_client/client_multi_workspace_spec.rb b/spec/seam_client/client_multi_workspace_spec.rb index 055c1207..8b6c389c 100644 --- a/spec/seam_client/client_multi_workspace_spec.rb +++ b/spec/seam_client/client_multi_workspace_spec.rb @@ -1,66 +1,70 @@ # frozen_string_literal: true -require "spec_helper" +RSpec.describe Seam::Http::MultiWorkspace, :fake do + describe ".from_personal_access_token" do + it "returns an instance authorized with the personal access token" do + seam = described_class.from_personal_access_token( + seed["seam_at1_token"], + endpoint: endpoint + ) -RSpec.describe Seam::Http::MultiWorkspace do - let(:personal_access_token) { "seam_at_12345" } - let(:endpoint) { "https://example.com" } - let(:client) { described_class.from_personal_access_token(personal_access_token, endpoint: endpoint) } + workspaces = seam.workspaces.list - describe ".from_personal_access_token" do - it "creates a new instance with the given token and endpoint" do - expect(client).to be_a(Seam::Http::MultiWorkspace) - expect(client.instance_variable_get(:@auth_headers)).to include("authorization" => "Bearer #{personal_access_token}") - expect(client.instance_variable_get(:@endpoint)).to eq(endpoint) + expect(workspaces.length).to be > 0 + expect(workspaces.first).to be_a(Seam::Resources::Workspace) end end - describe "#workspaces" do - it "creates a new workspace" do - name = "Test Workspace" - connect_partner_name = "Example Partner" - is_sandbox = true + describe "#initialize" do + it "returns an instance authorized with the personal access token" do + seam = described_class.new( + personal_access_token: seed["seam_at1_token"], + endpoint: endpoint + ) + + expect(seam.workspaces.list.length).to be > 0 + end + end - stub_request(:post, "#{endpoint}/workspaces/create") - .with( - body: { - name: name, - connect_partner_name: connect_partner_name, - is_sandbox: is_sandbox - }.to_json, - headers: { - "Content-Type" => "application/json" - } - ) - .to_return(status: 200, body: {workspace: {workspace_id: "ws_123456"}}.to_json, headers: {"Content-Type" => "application/json"}) + describe "#workspaces" do + it "creates a workspace" do + seam = described_class.from_personal_access_token( + seed["seam_at1_token"], + endpoint: endpoint + ) - workspace = client.workspaces.create( - name: name, - connect_partner_name: connect_partner_name, - is_sandbox: is_sandbox + workspace = seam.workspaces.create( + name: "Test Workspace", + connect_partner_name: "Example Partner", + is_sandbox: true ) expect(workspace).to be_a(Seam::Resources::Workspace) - expect(workspace.workspace_id).to eq("ws_123456") + expect(workspace.workspace_id).to be_a(String) end end - describe "token format validation" do - it "raises SeamInvalidTokenError for invalid token formats" do + describe "personal access token format" do + it "rejects tokens that are not personal access tokens" do expect do - described_class.from_personal_access_token("invalid_token") + described_class.from_personal_access_token("some-invalid-key-format") end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Unknown/) + expect do described_class.from_personal_access_token("seam_apikey_token") - end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, - /Unknown/) + end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Unknown/) + expect do described_class.from_personal_access_token("seam_cst") - end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, - /Client Session Token/) + end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Client Session Token/) + expect do described_class.from_personal_access_token("ey") end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /JWT/) + + expect do + described_class.from_personal_access_token("seam_pk_token") + end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Publishable Key/) end end end diff --git a/spec/seam_client/defaults_spec.rb b/spec/seam_client/defaults_spec.rb new file mode 100644 index 00000000..bb4a4240 --- /dev/null +++ b/spec/seam_client/defaults_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Http::SingleWorkspace do + describe "#defaults" do + it "waits for action attempts when constructed with new" do + seam = Seam.new(api_key: "seam_some_api_key") + + expect(seam.defaults.wait_for_action_attempt).to be true + end + + it "can be turned off" do + seam = Seam.new(api_key: "seam_some_api_key", wait_for_action_attempt: false) + + expect(seam.defaults.wait_for_action_attempt).to be false + end + + # Seam.from_api_key defaults wait_for_action_attempt to false while Seam.new + # defaults it to true. The JavaScript SDK waits by default in both cases. + it "does not wait when constructed with from_api_key" do + seam = Seam.from_api_key("seam_some_api_key") + + expect(seam.defaults.wait_for_action_attempt).to be false + end + end + + describe "#lts_version" do + it "is exposed on the instance and the module" do + seam = Seam.new(api_key: "seam_some_api_key") + + expect(seam.lts_version).to eq(Seam::LTS_VERSION) + expect(Seam.lts_version).to eq(Seam::LTS_VERSION) + end + end +end diff --git a/spec/seam_client/env_spec.rb b/spec/seam_client/env_spec.rb index 3041c97f..4b9a734d 100644 --- a/spec/seam_client/env_spec.rb +++ b/spec/seam_client/env_spec.rb @@ -1,97 +1,95 @@ # frozen_string_literal: true -RSpec.describe Seam::Http do - before(:each) do - cleanup_env - end - - after(:each) do - cleanup_env - end - +RSpec.describe Seam::Http, :fake do + # Each example needs a clean environment, so the variables the SDK reads are + # cleared before and after every one of them. def cleanup_env - ENV.delete("SEAM_API_KEY") - ENV.delete("SEAM_ENDPOINT") - ENV.delete("SEAM_API_URL") + %w[SEAM_API_KEY SEAM_ENDPOINT SEAM_API_URL].each { |name| ENV.delete(name) } end - let(:device_hash) { {device_id: "123"} } + before { cleanup_env } + + after { cleanup_env } describe "#initialize" do - it "uses SEAM_API_KEY environment variable" do - ENV["SEAM_API_KEY"] = "seam_some_api_key" - seam = Seam.new + it "uses the SEAM_API_KEY environment variable" do + ENV["SEAM_API_KEY"] = seed["seam_apikey1_token"] + + device = Seam.new(endpoint: endpoint).devices.get(device_id: seed["august_device_1"]) - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) end - it "api_key option overrides environment variables" do + it "prefers the api_key option over the environment" do ENV["SEAM_API_KEY"] = "some-invalid-api-key-1" - seam = Seam.new(api_key: "seam_some_api_key") - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + device = Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) + .devices.get(device_id: seed["august_device_1"]) + + expect(device.device_id).to eq(seed["august_device_1"]) end - it "requires api_key when passed no argument" do + it "requires an api_key when passed no argument" do expect do Seam.new end.to raise_error(Seam::Http::Options::SeamInvalidOptionsError, /api_key/) end - it "uses SEAM_ENDPOINT environment variable first" do + it "prefers SEAM_ENDPOINT over SEAM_API_URL" do ENV["SEAM_API_URL"] = "https://example.com" - ENV["SEAM_ENDPOINT"] = Seam::DEFAULT_ENDPOINT - seam = Seam.new(api_key: "seam_some_api_key") + ENV["SEAM_ENDPOINT"] = endpoint - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + device = Seam.new(api_key: seed["seam_apikey1_token"]) + .devices.get(device_id: seed["august_device_1"]) + + expect(device.device_id).to eq(seed["august_device_1"]) end - it "uses SEAM_API_URL environment variable as fallback" do - ENV["SEAM_API_URL"] = Seam::DEFAULT_ENDPOINT - seam = Seam.new(api_key: "seam_some_api_key") + it "falls back to the SEAM_API_URL environment variable" do + ENV["SEAM_API_URL"] = endpoint + + device = Seam.new(api_key: seed["seam_apikey1_token"]) + .devices.get(device_id: seed["august_device_1"]) - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + expect(device.device_id).to eq(seed["august_device_1"]) end - it "endpoint option overrides environment variables" do + it "prefers the endpoint option over the environment" do ENV["SEAM_API_URL"] = "https://example.com" ENV["SEAM_ENDPOINT"] = "https://example.com" - seam = Seam.new(api_key: "seam_some_api_key", endpoint: Seam::DEFAULT_ENDPOINT) - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + device = Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) + .devices.get(device_id: seed["august_device_1"]) + + expect(device.device_id).to eq(seed["august_device_1"]) end + end - it "uses SEAM_ENDPOINT environment variable with from_api_key" do + describe ".from_api_key" do + it "uses the SEAM_ENDPOINT environment variable" do ENV["SEAM_API_URL"] = "https://example.com" - ENV["SEAM_ENDPOINT"] = Seam::DEFAULT_ENDPOINT - seam = Seam.from_api_key("seam_some_api_key") + ENV["SEAM_ENDPOINT"] = endpoint + + device = Seam.from_api_key(seed["seam_apikey1_token"]) + .devices.get(device_id: seed["august_device_1"]) - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + expect(device.device_id).to eq(seed["august_device_1"]) end + end - it "ignores SEAM_API_KEY environment variable with personal access token" do - ENV["SEAM_API_KEY"] = "seam_some_api_key" + describe ".from_personal_access_token" do + it "ignores the SEAM_API_KEY environment variable" do + ENV["SEAM_API_KEY"] = "some-invalid-api-key-2" - seam = Seam.from_personal_access_token( - "seam_at1_token", - "workspace_123" - ) + device = Seam.from_personal_access_token( + seed["seam_at1_token"], + seed["seed_workspace_1"], + endpoint: endpoint + ).devices.get(device_id: seed["august_device_1"]) - stub_seam_request(:post, "/devices/list", {devices: [device_hash]}) - devices = seam.devices.list - expect(devices.length).to be > 0 + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) end end end diff --git a/spec/seam_client/faraday_options_spec.rb b/spec/seam_client/faraday_options_spec.rb index 8e87ae26..231dd141 100644 --- a/spec/seam_client/faraday_options_spec.rb +++ b/spec/seam_client/faraday_options_spec.rb @@ -1,46 +1,70 @@ # frozen_string_literal: true -require "spec_helper" - -RSpec.describe Seam::Http::SingleWorkspace do - let(:api_key) { "seam_test_api_key" } - let(:endpoint) { "https://example.com/api" } - let(:faraday_options) do - { - headers: {"Custom-Header" => "Test-Value"}, - request: {timeout: 30} - } - end - - describe "client options" do - it "passes faraday_options to the Faraday client" do - expect(Faraday).to receive(:new).with( - hash_including( - headers: hash_including("Custom-Header" => "Test-Value"), +RSpec.describe Seam::Http::SingleWorkspace, :fake do + describe "faraday_options" do + it "merges custom options into the Faraday client" do + seam = described_class.new( + api_key: seed["seam_apikey1_token"], + endpoint: endpoint, + faraday_options: { + headers: {"Custom-Header" => "Test-Value"}, request: {timeout: 30} - ) - ).and_call_original + } + ) - client = described_class.new( - api_key: api_key, + expect(seam.client.headers["Custom-Header"]).to eq("Test-Value") + expect(seam.client.options.timeout).to eq(30) + end + + it "keeps the auth and SDK headers when custom headers are given" do + seam = described_class.new( + api_key: seed["seam_apikey1_token"], endpoint: endpoint, - faraday_options: faraday_options + faraday_options: {headers: {"Custom-Header" => "Test-Value"}} ) - expect(client).to be_a(Seam::Http::SingleWorkspace) + expect(seam.client.headers["Authorization"]).to eq("Bearer #{seed["seam_apikey1_token"]}") + expect(seam.client.headers["seam-sdk-name"]).to eq("seamapi/ruby") end - it "merges faraday_options with default options" do - client = described_class.new( - api_key: api_key, + it "still authorizes requests against the server" do + seam = described_class.new( + api_key: seed["seam_apikey1_token"], endpoint: endpoint, - faraday_options: faraday_options + faraday_options: {headers: {"Custom-Header" => "Test-Value"}} ) - faraday_client = client.instance_variable_get(:@client) - expect(faraday_client.headers["Custom-Header"]).to eq("Test-Value") - expect(faraday_client.options.timeout).to eq(30) - expect(faraday_client.headers["Authorization"]).to eq("Bearer #{api_key}") + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.device_id).to eq(seed["august_device_1"]) + end + end + + describe "client option" do + it "reuses a Faraday client from another instance" do + # initialize parses the auth options before it considers the client, so a + # client cannot be supplied on its own the way SeamHttp.fromClient allows + # in the JavaScript SDK. + pending "the client option still requires an api_key or personal_access_token" + + seam = described_class.new( + client: described_class.new( + api_key: seed["seam_apikey1_token"], + endpoint: endpoint + ).client + ) + + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) + end + + it "can be used to make requests directly" do + response = seam.client.post("/devices/get", {device_id: seed["august_device_1"]}) + + expect(response.status).to eq(200) + expect(response.body["device"]["device_id"]).to eq(seed["august_device_1"]) end end end diff --git a/spec/seam_client/faraday_retry_options_spec.rb b/spec/seam_client/faraday_retry_options_spec.rb deleted file mode 100644 index 749dc297..00000000 --- a/spec/seam_client/faraday_retry_options_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Seam::Http::SingleWorkspace do - let(:api_key) { "seam_test_api_key" } - let(:endpoint) { "https://example.com" } - let(:faraday_retry_options) do - { - max: 3, - interval: 0.1, - methods: %i[post], - retry_statuses: [500] - } - end - - describe "retry options" do - it "passes faraday_retry_options to the Faraday client and uses them" do - client = described_class.new( - api_key: api_key, - endpoint: endpoint, - faraday_retry_options: faraday_retry_options - ) - - stub_request(:post, "#{endpoint}/devices/list") - .with( - headers: { - "Content-Type" => "application/json" - } - ) - .to_return(status: 500, body: {"error" => {"type" => "server_error", "message" => "Internal Server Error"}}.to_json, headers: {"Content-Type" => "application/json"}) - .to_return(status: 500, body: {"error" => {"type" => "server_error", "message" => "Internal Server Error"}}.to_json, headers: {"Content-Type" => "application/json"}) - .to_return(status: 200, body: {devices: []}.to_json, headers: {"Content-Type" => "application/json"}) - - result = client.devices.list - expect(result).to eq([]) - - expect(a_request(:post, "#{endpoint}/devices/list")).to have_been_made.times(3) - end - end -end diff --git a/spec/seam_client/headers_spec.rb b/spec/seam_client/headers_spec.rb new file mode 100644 index 00000000..e27e6e53 --- /dev/null +++ b/spec/seam_client/headers_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# The fake does not echo the request back, so WebMock is used here to assert on +# the headers the SDK actually sends. +RSpec.describe Seam::Http::Request do + let(:device_id) { "device_id_1234" } + + it "sends the SDK and auth headers" do + stub = stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/get") + .with( + body: {device_id: device_id}.to_json, + headers: { + "Authorization" => "Bearer seam_some_api_key", + "Content-Type" => "application/json", + "seam-sdk-name" => "seamapi/ruby", + "seam-sdk-version" => Seam::VERSION, + "seam-lts-version" => Seam::LTS_VERSION, + "User-Agent" => "seam-ruby/#{Seam::VERSION}" + } + ) + .to_return( + status: 200, + body: {device: {device_id: device_id}}.to_json, + headers: {"Content-Type" => "application/json"} + ) + + seam = Seam.new(api_key: "seam_some_api_key") + device = seam.devices.get(device_id: device_id) + + expect(device.device_id).to eq(device_id) + expect(stub).to have_been_requested + end + + it "sends the workspace header with a personal access token" do + stub = stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/get") + .with( + headers: { + "Authorization" => "Bearer seam_at_token", + "seam-workspace" => "workspace_123" + } + ) + .to_return( + status: 200, + body: {device: {device_id: device_id}}.to_json, + headers: {"Content-Type" => "application/json"} + ) + + seam = Seam.from_personal_access_token("seam_at_token", "workspace_123") + seam.devices.get(device_id: device_id) + + expect(stub).to have_been_requested + end + + it "exposes the LTS version on the module and the client" do + expect(Seam.lts_version).to eq(Seam::LTS_VERSION) + expect(Seam.new(api_key: "seam_some_api_key").lts_version).to eq(Seam::LTS_VERSION) + end +end diff --git a/spec/seam_client/http_error_spec.rb b/spec/seam_client/http_error_spec.rb index 0c90a9a9..e3025dde 100644 --- a/spec/seam_client/http_error_spec.rb +++ b/spec/seam_client/http_error_spec.rb @@ -1,51 +1,61 @@ -require "spec_helper" +# frozen_string_literal: true -RSpec.describe Seam::Http::InvalidInputError do - let(:api_key) { "seam_apikey1_token" } - let(:client) { Seam.new(api_key: api_key) } +RSpec.describe Seam::Http, :fake do + describe "unauthorized responses" do + it "raises UnauthorizedError" do + seam = Seam.new(api_key: "seam_invalid_api_key", endpoint: endpoint) - describe "handling invalid input errors" do - let(:error_response) do - { - error: { - type: "invalid_input", - message: "Invalid input", - validation_errors: { - device_ids: { - _errors: ["Expected array, received number"] - } - } - } - } + expect { seam.devices.list }.to raise_error(Seam::Http::UnauthorizedError) do |error| + expect(error.status_code).to eq(401) + expect(error.code).to eq("unauthorized") + expect(error.request_id).to start_with("request") + end end + end - it "raises InvalidInputError with correct validation messages" do - stub_seam_request(:post, "/devices/list", - error_response, - status: 400).with do |req| - req.body == {device_ids: 123}.to_json + describe "standard error responses" do + it "raises ApiError" do + expect do + seam.devices.get(device_id: "unknown-device") + end.to raise_error(Seam::Http::ApiError) do |error| + expect(error.status_code).to eq(404) + expect(error.code).to eq("device_not_found") + expect(error.request_id).to start_with("request") end + end + end + describe "invalid input responses" do + it "raises InvalidInputError carrying the validation errors" do expect do - client.devices.list(device_ids: 123) + seam.client.post("/devices/list", {device_ids: 4242}) end.to raise_error(Seam::Http::InvalidInputError) do |error| - expect(error.code).to eq("invalid_input") expect(error.status_code).to eq(400) - expect(error.get_validation_error_messages("device_ids")).to eq(["Expected array, received number"]) + expect(error.code).to eq("invalid_input") + expect(error.request_id).to start_with("request") + expect(error.get_validation_error_messages("device_ids")) + .to eq(["Expected array, received number"]) end end - it "returns an empty array for non-existent validation errors" do - stub_seam_request(:post, "/devices/list", - error_response, - status: 400).with do |req| - req.body == {device_ids: 123}.to_json + it "returns no messages for a param without validation errors" do + expect do + seam.client.post("/devices/list", {device_ids: 4242}) + end.to raise_error(Seam::Http::InvalidInputError) do |error| + expect(error.get_validation_error_messages("non_existent_param")).to eq([]) end + end + end + + describe "non-standard error responses" do + it "raises a Faraday error" do + seam.client.post( + "/_fake/simulate_workspace_outage", + {workspace_id: seed["seed_workspace_1"], routes: ["/devices/list"]} + ) - begin - client.devices.list(device_ids: 123) - rescue Seam::Http::InvalidInputError => e - expect(e.get_validation_error_messages("non_existent_field")).to eq([]) + expect { seam.devices.list }.to raise_error(Faraday::Error) do |error| + expect(error.response[:status]).to eq(503) end end end diff --git a/spec/seam_client/init_seam_spec.rb b/spec/seam_client/init_seam_spec.rb deleted file mode 100644 index 3d342556..00000000 --- a/spec/seam_client/init_seam_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Http do - let(:seam) { Seam.new(api_key: "seam_some_api_key") } - - describe "#initialize" do - it "initializes Seam with fixture" do - expect(Seam.lts_version).not_to be_nil - expect(seam.lts_version).not_to be_nil - expect(seam.defaults.wait_for_action_attempt).to be true - end - end -end diff --git a/spec/seam_client/personal_access_token_spec.rb b/spec/seam_client/personal_access_token_spec.rb index 8ea8c8f0..d18609d9 100644 --- a/spec/seam_client/personal_access_token_spec.rb +++ b/spec/seam_client/personal_access_token_spec.rb @@ -1,10 +1,59 @@ # frozen_string_literal: true -RSpec.describe Seam::Http do - let(:workspace_id) { "e4203e37-e569-4a5a-bfb7-e3e8de66161d" } +RSpec.describe Seam::Http, :fake do + # UPSTREAM: The fake rejects a personal access token on /devices/list but + # authorizes it on /devices/get, which is the route these specs use. + describe ".from_personal_access_token" do + it "returns an instance authorized with the personal access token" do + seam = Seam.from_personal_access_token( + seed["seam_at1_token"], + seed["seed_workspace_1"], + endpoint: endpoint + ) - describe "#from_personal_access_token" do - it "raises error for invalid personal access token formats" do + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) + end + end + + describe "#initialize" do + it "returns an instance authorized with the personal access token" do + seam = Seam.new( + personal_access_token: seed["seam_at1_token"], + workspace_id: seed["seed_workspace_1"], + endpoint: endpoint + ) + + device = seam.devices.get(device_id: seed["august_device_1"]) + + expect(device.workspace_id).to eq(seed["seed_workspace_1"]) + expect(device.device_id).to eq(seed["august_device_1"]) + end + + it "requires a workspace_id" do + expect do + Seam.new(personal_access_token: seed["seam_at1_token"], endpoint: endpoint) + end.to raise_error(Seam::Http::Options::SeamInvalidOptionsError, /workspace_id/) + end + + it "cannot be combined with an api_key" do + expect do + Seam.new( + api_key: seed["seam_apikey1_token"], + personal_access_token: seed["seam_at1_token"], + workspace_id: seed["seed_workspace_1"], + endpoint: endpoint + ) + end.to raise_error(Seam::Http::Options::SeamInvalidOptionsError, /cannot be used with/) + end + end + + describe "personal access token format" do + let(:workspace_id) { "e4203e37-e569-4a5a-bfb7-e3e8de66161d" } + + it "rejects tokens that are not personal access tokens" do expect do Seam.from_personal_access_token("some-invalid-key-format", workspace_id) end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Unknown/) @@ -20,6 +69,10 @@ expect do Seam.from_personal_access_token("ey", workspace_id) end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /JWT/) + + expect do + Seam.from_personal_access_token("seam_pk_token", workspace_id) + end.to raise_error(Seam::Http::Auth::SeamInvalidTokenError, /Publishable Key/) end end end diff --git a/spec/seam_client/request_spec.rb b/spec/seam_client/request_spec.rb deleted file mode 100644 index 397cdf8d..00000000 --- a/spec/seam_client/request_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Seam::Http do - let(:seam) { Seam.new(api_key: "seam_some_api_key") } - let(:request_id) { "request_id_1234" } - - describe "#request_seam" do - context "when unauthorized error occurs" do - before do - stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/list") - .to_return(status: 401, headers: {"seam-request-id" => request_id}) - end - - it "raises UnauthorizedError" do - expect { seam.devices.list }.to raise_error(Seam::Http::UnauthorizedError) do |error| - expect(error.message).to eq("Unauthorized") - expect(error.request_id).to eq(request_id) - end - end - end - - context "when invalid input error occurs" do - let(:error_message) { "Invalid device_id provided" } - let(:error_status) { 400 } - let(:error_response) do - { - error: { - type: "invalid_input", - message: error_message - } - }.to_json - end - - before do - stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/get") - .to_return(status: error_status, body: error_response, headers: {"Content-Type" => "application/json", - "seam-request-id" => request_id}) - end - - it "raises InvalidInputError" do - expect do - seam.devices.get(device_id: "invalid_device_id") - end.to raise_error(Seam::Http::InvalidInputError) do |error| - expect(error.message).to eq(error_message) - expect(error.status_code).to eq(error_status) - expect(error.request_id).to eq(request_id) - end - end - end - - context "when non-Seam API error occurs" do - let(:error_status) { 500 } - let(:error_response) { "Internal Server Error" } - - before do - stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/list") - .to_return(status: error_status, body: error_response, headers: {"Content-Type" => "text/plain"}) - end - - it "raises Faraday error" do - expect { seam.devices.list }.to raise_error(Faraday::ServerError) - end - end - - context "when malformed JSON response" do - let(:error_status) { 500 } - let(:error_response) { "{invalid json" } - - before do - stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/list") - .to_return(status: error_status, body: error_response, headers: {"Content-Type" => "application/json"}) - end - - it "raises Faraday error" do - expect { seam.devices.list }.to raise_error(Faraday::ServerError) - end - end - - context "when JSON response without error object" do - let(:error_status) { 500 } - let(:error_response) { '{"message": "Some error"}' } - - before do - stub_request(:post, "#{Seam::DEFAULT_ENDPOINT}/devices/list") - .to_return(status: error_status, body: error_response, headers: {"Content-Type" => "application/json"}) - end - - it "raises Faraday error" do - expect { seam.devices.list }.to raise_error(Faraday::ServerError) - end - end - end -end diff --git a/spec/seam_client/retry_spec.rb b/spec/seam_client/retry_spec.rb new file mode 100644 index 00000000..9d62a475 --- /dev/null +++ b/spec/seam_client/retry_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Http::Request do + let(:url) { "#{Seam::DEFAULT_ENDPOINT}/devices/list" } + + def service_unavailable + { + status: 503, + body: {error: {type: "service_unavailable", message: "Down"}}.to_json, + headers: {"Content-Type" => "application/json"} + } + end + + def devices + { + status: 200, + body: {devices: []}.to_json, + headers: {"Content-Type" => "application/json"} + } + end + + # WebMock is used here because counting attempts is the point, and the fake + # keeps a simulated outage in place for every request. + describe "faraday_retry_options" do + it "retries until the request succeeds" do + stub_request(:post, url) + .to_return(service_unavailable) + .to_return(service_unavailable) + .to_return(devices) + + seam = Seam.new( + api_key: "seam_some_api_key", + faraday_retry_options: {max: 3, interval: 0, methods: %i[post], retry_statuses: [503]} + ) + + expect(seam.devices.list).to eq([]) + expect(a_request(:post, url)).to have_been_made.times(3) + end + + it "gives up once the retries are exhausted" do + stub_request(:post, url).to_return(service_unavailable) + + seam = Seam.new( + api_key: "seam_some_api_key", + faraday_retry_options: {max: 2, interval: 0, methods: %i[post], retry_statuses: [503]} + ) + + expect { seam.devices.list }.to raise_error(Seam::Http::ApiError) + expect(a_request(:post, url)).to have_been_made.times(3) + end + + it "does not retry when max is zero" do + stub_request(:post, url).to_return(service_unavailable) + + seam = Seam.new( + api_key: "seam_some_api_key", + faraday_retry_options: {max: 0, interval: 0, methods: %i[post], retry_statuses: [503]} + ) + + expect { seam.devices.list }.to raise_error(Seam::Http::ApiError) + expect(a_request(:post, url)).to have_been_made.times(1) + end + + it "does not retry POST requests by default" do + stub_request(:post, url).to_return(service_unavailable) + + seam = Seam.new(api_key: "seam_some_api_key", faraday_retry_options: {interval: 0}) + + expect { seam.devices.list }.to raise_error(Seam::Http::ApiError) + expect(a_request(:post, url)).to have_been_made.times(1) + end + end + + describe "a workspace outage", :fake do + it "surfaces the error to the caller" do + seam.client.post( + "/_fake/simulate_workspace_outage", + {workspace_id: seed["seed_workspace_1"], routes: ["/devices/list"]} + ) + + expect { seam.devices.list }.to raise_error(Faraday::Error) do |error| + expect(error.response[:status]).to eq(503) + end + end + end +end diff --git a/spec/seam_client/wait_for_action_attempt_spec.rb b/spec/seam_client/wait_for_action_attempt_spec.rb new file mode 100644 index 00000000..82bf86c4 --- /dev/null +++ b/spec/seam_client/wait_for_action_attempt_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Helpers::ActionAttempt, :fake do + let(:seam) do + Seam.new( + api_key: seed["seam_apikey1_token"], + endpoint: endpoint, + wait_for_action_attempt: false + ) + end + + def unlock_door + seam.locks.unlock_door(device_id: seed["august_device_1"]) + end + + def update_action_attempt(action_attempt, attributes) + seam.client.post( + "/_fake/update_action_attempt", + {action_attempt_id: action_attempt.action_attempt_id}.merge(attributes) + ) + end + + it "does not wait when set to false on the client" do + expect(unlock_door.status).to eq("pending") + end + + it "waits by default" do + seam = Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) + + action_attempt = seam.locks.unlock_door(device_id: seed["august_device_1"]) + + expect(action_attempt.status).to eq("success") + end + + it "waits when set on the method call" do + action_attempt = seam.locks.unlock_door( + device_id: seed["august_device_1"], + wait_for_action_attempt: true + ) + + expect(action_attempt.status).to eq("success") + end + + it "can be set to a hash of options on the client" do + # SingleWorkspace wraps its defaults in a DeepHashAccessor, so the hash form + # of this option no longer satisfies the is_a?(Hash) check in + # Helpers::ActionAttempt.decide_and_wait and the client never waits. + pending "the hash form of the client default is wrapped in a DeepHashAccessor" + + seam = Seam.new( + api_key: seed["seam_apikey1_token"], + endpoint: endpoint, + wait_for_action_attempt: {timeout: 5} + ) + + action_attempt = seam.locks.unlock_door(device_id: seed["august_device_1"]) + + expect(action_attempt.status).to eq("success") + end + + it "can be set to a hash of options on the method call" do + action_attempt = seam.locks.unlock_door( + device_id: seed["august_device_1"], + wait_for_action_attempt: {timeout: 5} + ) + + expect(action_attempt.status).to eq("success") + end + + it "waits for a pending action attempt to succeed" do + action_attempt = unlock_door + expect(action_attempt.status).to eq("pending") + + update_action_attempt(action_attempt, {status: "pending"}) + + Thread.new do + sleep 1 + update_action_attempt(action_attempt, {status: "success"}) + end + + resolved = seam.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: true + ) + + expect(resolved.status).to eq("success") + end + + it "returns an already successful action attempt" do + action_attempt = unlock_door + expect(action_attempt.status).to eq("pending") + + update_action_attempt(action_attempt, {status: "success"}) + + successful = seam.action_attempts.get(action_attempt_id: action_attempt.action_attempt_id) + expect(successful.status).to eq("success") + + resolved = seam.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: true + ) + + expect(resolved.action_attempt_id).to eq(successful.action_attempt_id) + expect(resolved.status).to eq(successful.status) + end + + it "times out while waiting for a pending action attempt" do + action_attempt = unlock_door + update_action_attempt(action_attempt, {status: "pending"}) + + expect do + seam.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: {timeout: 0.1} + ) + end.to raise_error(Seam::ActionAttemptTimeoutError) do |error| + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + expect(error.action_attempt.status).to eq("pending") + end + end + + it "times out while waiting for the polling interval" do + action_attempt = unlock_door + update_action_attempt(action_attempt, {status: "pending"}) + + expect do + seam.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: {timeout: 0.5, polling_interval: 3} + ) + end.to raise_error(Seam::ActionAttemptTimeoutError) do |error| + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + end + end + + it "raises when the action attempt fails" do + action_attempt = unlock_door + update_action_attempt( + action_attempt, + {status: "error", error: {message: "Failed", type: "foo"}} + ) + + expect do + seam.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: true + ) + end.to raise_error(Seam::ActionAttemptFailedError) do |error| + expect(error.message).to include("Failed") + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + expect(error.action_attempt.status).to eq("error") + expect(error.code).to eq("foo") + end + end +end diff --git a/spec/seam_client/wait_for_action_attepmt_spec.rb b/spec/seam_client/wait_for_action_attepmt_spec.rb deleted file mode 100644 index 59bab289..00000000 --- a/spec/seam_client/wait_for_action_attepmt_spec.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Seam::Http do - let(:api_key) { "seam_apikey1_token" } - let(:device_id) { "august_device_1" } - - describe "action attempt handling" do - let(:success_response) do - { - action_attempt: { - action_attempt_id: "1234", - status: "success" - } - } - end - - let(:pending_response) do - { - action_attempt: { - action_attempt_id: "1234", - status: "pending" - } - } - end - - let(:error_response) do - { - action_attempt: { - action_attempt_id: "1234", - status: "error", - error: {message: "Failed", type: "foo"} - } - } - end - - it "waits for action attempt when specified on method call" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", success_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id, wait_for_action_attempt: true) - expect(action_attempt.status).to eq("success") - end - - it "waits for action attempt by default" do - client = described_class.new(api_key: api_key) - - stub_seam_request(:post, "/locks/unlock_door", success_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("success") - end - - it "doesn't wait for action attempt when set to false in client initialization" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", pending_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("pending") - end - - it "can set class default with an object in client initialization" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: {timeout: 5}) - - stub_seam_request(:post, "/locks/unlock_door", success_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("success") - end - - it "returns successful action attempt" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", pending_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("pending") - - stub_seam_request(:post, "/action_attempts/get", success_response) - .with { |req| req.body == {action_attempt_id: "1234"}.to_json } - - successful_action_attempt = client.action_attempts.get( - action_attempt_id: action_attempt.action_attempt_id - ) - - expect(successful_action_attempt.status).to eq("success") - - resolved_action_attempt = client.action_attempts.get( - action_attempt_id: action_attempt.action_attempt_id, - wait_for_action_attempt: true - ) - - expect(resolved_action_attempt.action_attempt_id).to eq(successful_action_attempt.action_attempt_id) - expect(resolved_action_attempt.status).to eq(successful_action_attempt.status) - end - - it "times out when waiting for action attempt" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", pending_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("pending") - - stub_seam_request(:post, "/action_attempts/get", pending_response) - .with { |req| req.body == {action_attempt_id: "1234"}.to_json } - - expect do - client.action_attempts.get( - action_attempt_id: action_attempt.action_attempt_id, - wait_for_action_attempt: {timeout: 0.1} - ) - end.to raise_error(Seam::ActionAttemptTimeoutError) do |error| - expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) - expect(error.action_attempt.status).to eq(action_attempt.status) - end - end - - it "rejects when action attempt fails" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", pending_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("pending") - - stub_seam_request(:post, "/action_attempts/get", error_response) - .with { |req| req.body == {action_attempt_id: "1234"}.to_json } - - expect do - client.action_attempts.get( - action_attempt_id: action_attempt.action_attempt_id, - wait_for_action_attempt: true - ) - end.to raise_error(Seam::ActionAttemptFailedError) do |error| - expect(error.message).to include("Failed") - expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) - expect(error.action_attempt.status).to eq("error") - expect(error.code).to eq("foo") - end - end - - it "times out if waiting for polling interval" do - client = described_class.new(api_key: api_key, wait_for_action_attempt: false) - - stub_seam_request(:post, "/locks/unlock_door", pending_response) - .with { |req| req.body == {device_id: device_id}.to_json } - - action_attempt = client.locks.unlock_door(device_id: device_id) - expect(action_attempt.status).to eq("pending") - - stub_seam_request(:post, "/action_attempts/get", pending_response) - .with { |req| req.body == {action_attempt_id: "1234"}.to_json } - - expect do - client.action_attempts.get( - action_attempt_id: action_attempt.action_attempt_id, - wait_for_action_attempt: {timeout: 0.5, polling_interval: 3} - ) - end.to raise_error(Seam::ActionAttemptTimeoutError) do |error| - expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) - expect(error.action_attempt.status).to eq(action_attempt.status) - end - end - end -end diff --git a/spec/serialization_spec.rb b/spec/serialization_spec.rb new file mode 100644 index 00000000..93b78a3a --- /dev/null +++ b/spec/serialization_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Http, :fake do + describe "array params" do + it "omits the param when it is nil" do + devices = seam.devices.list(device_ids: nil) + database = seam.client.get("/_fake/database").body + + expect(devices.length).to eq(database["devices"].length) + end + + it "sends an empty array" do + expect(seam.devices.list(device_ids: []).length).to eq(0) + end + + it "sends a non-empty array" do + devices = seam.devices.list( + device_ids: [seed["august_device_1"], seed["ecobee_device_1"]] + ) + + expect(devices.length).to eq(2) + + device_ids = devices.map(&:device_id) + expect(device_ids).to include(seed["august_device_1"]) + expect(device_ids).to include(seed["ecobee_device_1"]) + end + + it "sends a non-empty array when using the client directly" do + response = seam.client.post( + "/devices/list", + {device_ids: [seed["august_device_1"], seed["ecobee_device_1"]]} + ) + + device_ids = response.body["devices"].map { |device| device["device_id"] } + + expect(device_ids.length).to eq(2) + expect(device_ids).to include(seed["august_device_1"]) + expect(device_ids).to include(seed["ecobee_device_1"]) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e89fbc10..294aad51 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,18 +3,31 @@ require "simplecov" require "simplecov-console" -SimpleCov.start +SimpleCov.start do + add_filter "/spec/" + + # Route and resource classes are generated from the API definition. Like the + # JavaScript SDK, the specs cover SDK behavior rather than generated code, so + # measuring coverage on it only creates pressure to test the generator. + add_filter "lib/seam/routes/" + add_filter "lib/seam/resources/" +end require "seam" require "webmock/rspec" -require "support/helpers" +require "support/fake_seam_connect" SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::Console ]) +# The fake runs on localhost. WebMock stays available for the few things the +# fake cannot do, namely asserting the request the SDK sends and counting +# retries, which are stubbed against the default endpoint. +WebMock.disable_net_connect!(allow_localhost: true) + RSpec.configure do |config| config.example_status_persistence_file_path = ".rspec_status" @@ -24,5 +37,5 @@ c.syntax = :expect end - config.include Helpers + config.include_context "with fake seam connect", fake: true end diff --git a/spec/support/fake_seam_connect.rb b/spec/support/fake_seam_connect.rb new file mode 100644 index 00000000..d8bcb63d --- /dev/null +++ b/spec/support/fake_seam_connect.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "socket" +require "timeout" +require "uri" + +# Runs a fake Seam Connect server for the duration of a single example. +# +# Prefer this over stubbing HTTP responses: the fake exercises the SDK against +# a real server and seeded records, which is how the JavaScript SDK is tested. +class FakeSeamConnect + STARTUP_TIMEOUT = 30 + SHUTDOWN_TIMEOUT = 10 + POLL_INTERVAL = 0.05 + + BIN = File.expand_path("../../node_modules/.bin/fake-seam-connect", __dir__) + + attr_reader :endpoint, :seed + + def self.start + new.start + end + + def initialize + @port = self.class.unused_port + @endpoint = "http://localhost:#{@port}" + end + + def start + unless File.executable?(BIN) + raise "Could not find #{BIN}, run npm install before the specs." + end + + @pid = Process.spawn( + {"PORT" => @port.to_s}, + BIN, "--seed", + out: File::NULL, + err: File::NULL + ) + + wait_for_health + @seed = fetch_seed + + self + end + + def stop + return if @pid.nil? + + Process.kill("TERM", @pid) + + begin + Timeout.timeout(SHUTDOWN_TIMEOUT) { Process.wait(@pid) } + rescue Timeout::Error + Process.kill("KILL", @pid) + Process.wait(@pid) + end + rescue Errno::ESRCH, Errno::ECHILD + # The server already exited. + ensure + @pid = nil + end + + def self.unused_port + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + server.close + port + end + + private + + def wait_for_health + deadline = monotonic_now + STARTUP_TIMEOUT + + while monotonic_now < deadline + raise "Fake Seam Connect exited before becoming healthy." if exited? + return if healthy? + + sleep POLL_INTERVAL + end + + raise "Fake Seam Connect did not become healthy within #{STARTUP_TIMEOUT}s." + end + + def healthy? + get("/health").is_a?(Net::HTTPSuccess) + rescue + false + end + + def exited? + !Process.wait(@pid, Process::WNOHANG).nil? + rescue Errno::ECHILD + true + end + + def fetch_seed + response = get("/_fake/default_seed") + + unless response.is_a?(Net::HTTPSuccess) + raise "Could not read the seed from Fake Seam Connect." + end + + JSON.parse(response.body) + end + + def get(path) + uri = URI.parse("#{endpoint}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 5 + http.get(uri.path) + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end +end + +# Included into examples tagged `fake: true`. +RSpec.shared_context "with fake seam connect" do + let(:fake) { FakeSeamConnect.start } + let(:endpoint) { fake.endpoint } + let(:seed) { fake.seed } + let(:seam) { Seam.new(api_key: seed["seam_apikey1_token"], endpoint: endpoint) } + + before { fake } + + after { fake.stop } +end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb deleted file mode 100644 index 7cf994aa..00000000 --- a/spec/support/helpers.rb +++ /dev/null @@ -1,106 +0,0 @@ -require "socket" -require "json" -require "net/http" -require "uri" - -module Helpers - DEFAULT_TIMEOUT = 30 - MAX_ATTEMPTS = 5 - - def stub_seam_request(method, path, response, status: 200, headers: {}) - stub_request( - method, - "#{Seam::DEFAULT_ENDPOINT}#{path}" - ).to_return( - status: status, - body: response.to_json, - headers: {"Content-Type" => "application/json"}.merge(headers) - ) - end - - def with_fake_seam_connect - port = find_available_port - ENV["PORT"] = port.to_s - endpoint = "http://localhost:#{port}" - - pid = start_server - WebMock.disable_net_connect!(allow_localhost: true) - - wait_for_server(endpoint) - seed = get_seed(endpoint) - seam = initialize_seam_client(endpoint, seed) - verify_server_health!(endpoint) - - yield seam, endpoint, seed - ensure - cleanup_server(pid) - end - - private - - def find_available_port - TCPServer.new("127.0.0.1", 0).tap do |server| - @port = server.addr[1] - server.close - end - @port - end - - def start_server - Process.spawn("npm run start", pgroup: true) - end - - def initialize_seam_client(endpoint, seed) - Seam.new( - endpoint: endpoint, - api_key: seed["seam_apikey1_token"] - ) - end - - def verify_server_health!(endpoint) - uri = URI.parse("#{endpoint}/health") - response = Net::HTTP.get_response(uri) - raise "Fake test server not healthy" unless response.is_a?(Net::HTTPSuccess) - end - - def wait_for_server(endpoint, max_attempts: MAX_ATTEMPTS, timeout: DEFAULT_TIMEOUT) - start_time = Time.now - attempts = 0 - - begin - uri = URI.parse("#{endpoint}/health") - http = Net::HTTP.new(uri.host, uri.port) - http.read_timeout = 5 - http.open_timeout = 5 - response = http.get(uri.path) - raise unless response.is_a?(Net::HTTPSuccess) - rescue => e - attempts += 1 - if attempts < max_attempts && (Time.now - start_time) < timeout - sleep(1) - retry - else - raise "Fake test server failed to start after #{attempts} attempts or #{timeout}s timeout: #{e.message}" - end - end - end - - def get_seed(endpoint) - uri = URI.parse("#{endpoint}/_fake/default_seed") - response = Net::HTTP.get(uri) - JSON.parse(response) - rescue => e - raise "Failed to get seed from fake test server: #{e.message}" - end - - def cleanup_server(pid) - return unless pid - - begin - Process.kill("-TERM", Process.getpgid(pid)) - Process.wait(pid) - rescue Errno::ESRCH, Errno::ECHILD - # Process already terminated - end - end -end From 15cccdde0cd2cabd5e5b9bef552486a61dc713e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:57:56 +0000 Subject: [PATCH 2/5] test: keep every spec under spec/ Move the DeepHashAccessor spec out of lib/seam so all specs live in one place, and drop the extra Rakefile pattern that existed to reach it. Rename the BaseResource hash spec after its subject, which is BaseResource rather than DeepHashAccessor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DzapiU8A9NMdyoTybL9xB --- Rakefile | 5 +---- {lib/seam => spec}/deep_hash_accessor_spec.rb | 0 ...deep_hash_accessor_spec.rb => base_resource_hash_spec.rb} | 0 3 files changed, 1 insertion(+), 4 deletions(-) rename {lib/seam => spec}/deep_hash_accessor_spec.rb (100%) rename spec/resources/{deep_hash_accessor_spec.rb => base_resource_hash_spec.rb} (100%) diff --git a/Rakefile b/Rakefile index 4b584e10..f80708c9 100644 --- a/Rakefile +++ b/Rakefile @@ -5,10 +5,7 @@ require "rspec/core/rake_task" require "standard/rake" RSpec::Core::RakeTask.new(:spec) do |t| - t.pattern = [ - "spec/**/*_spec.rb", - "lib/seam/*_spec.rb" - ] + t.pattern = "spec/**/*_spec.rb" end task default: %i[lint test] diff --git a/lib/seam/deep_hash_accessor_spec.rb b/spec/deep_hash_accessor_spec.rb similarity index 100% rename from lib/seam/deep_hash_accessor_spec.rb rename to spec/deep_hash_accessor_spec.rb diff --git a/spec/resources/deep_hash_accessor_spec.rb b/spec/resources/base_resource_hash_spec.rb similarity index 100% rename from spec/resources/deep_hash_accessor_spec.rb rename to spec/resources/base_resource_hash_spec.rb From 202dd259e7cc4f9b9429a2fdf6ceb59004a9a69a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:37:15 +0000 Subject: [PATCH 3/5] fix: allow the client option to be used on its own SingleWorkspace#initialize parsed the auth options before it considered the client, so passing only a client raised SeamInvalidOptionsError. Supplying an api_key alongside one made the option pointless, since the auth headers were built and then discarded with the client that would have carried them. Parse the auth options only when a client has to be built, so a client from another instance can be reused the way SeamHttp.fromClient allows in the JavaScript SDK. Drops the pending marker from the spec that covered this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DzapiU8A9NMdyoTybL9xB --- lib/seam/http_single_workspace.rb | 17 +++++++++++------ spec/seam_client/faraday_options_spec.rb | 5 ----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/seam/http_single_workspace.rb b/lib/seam/http_single_workspace.rb index e0756656..e7e693cd 100644 --- a/lib/seam/http_single_workspace.rb +++ b/lib/seam/http_single_workspace.rb @@ -18,13 +18,18 @@ class SingleWorkspace def initialize(client: nil, api_key: nil, personal_access_token: nil, workspace_id: nil, endpoint: nil, wait_for_action_attempt: true, faraday_options: {}, faraday_retry_options: {}) - options = Http::Options.parse_options(api_key: api_key, personal_access_token: personal_access_token, - workspace_id: workspace_id, endpoint: endpoint) - @endpoint = options[:endpoint] - @auth_headers = options[:auth_headers] @defaults = Seam::DeepHashAccessor.new({"wait_for_action_attempt" => wait_for_action_attempt}) - @client = client || Http::Request.create_faraday_client(@endpoint, @auth_headers, faraday_options, - faraday_retry_options) + + # A client carries its own endpoint and authorization, so the auth + # options are only parsed when one has to be built. + @client = client || begin + options = Http::Options.parse_options(api_key: api_key, personal_access_token: personal_access_token, + workspace_id: workspace_id, endpoint: endpoint) + @endpoint = options[:endpoint] + @auth_headers = options[:auth_headers] + + Http::Request.create_faraday_client(@endpoint, @auth_headers, faraday_options, faraday_retry_options) + end initialize_routes(client: @client, defaults: @defaults) end diff --git a/spec/seam_client/faraday_options_spec.rb b/spec/seam_client/faraday_options_spec.rb index 231dd141..6e7207d1 100644 --- a/spec/seam_client/faraday_options_spec.rb +++ b/spec/seam_client/faraday_options_spec.rb @@ -42,11 +42,6 @@ describe "client option" do it "reuses a Faraday client from another instance" do - # initialize parses the auth options before it considers the client, so a - # client cannot be supplied on its own the way SeamHttp.fromClient allows - # in the JavaScript SDK. - pending "the client option still requires an api_key or personal_access_token" - seam = described_class.new( client: described_class.new( api_key: seed["seam_apikey1_token"], From 3952041d2482b9e0f839c6bcb97c366869923b63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:37:15 +0000 Subject: [PATCH 4/5] fix: wait when wait_for_action_attempt is a hash on the client SingleWorkspace wraps its defaults in a DeepHashAccessor, so a hash passed as wait_for_action_attempt reached decide_and_wait as an accessor rather than a Hash. The is_a?(Hash) branch was skipped and the client returned a pending action attempt without waiting, so Seam.new(wait_for_action_attempt: {timeout: 5}) never waited. The same option passed on a method call arrives as a plain Hash and did work. Accept either form in decide_and_wait, and add DeepHashAccessor#to_h so the wrapped value can be read back as the hash it was built from. Drops the pending marker from the spec that covered this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DzapiU8A9NMdyoTybL9xB --- lib/seam/deep_hash_accessor.rb | 4 ++++ lib/seam/helpers/action_attempt.rb | 23 +++++++++++++------ .../wait_for_action_attempt_spec.rb | 5 ---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/seam/deep_hash_accessor.rb b/lib/seam/deep_hash_accessor.rb index 515e8775..88a9a9a6 100644 --- a/lib/seam/deep_hash_accessor.rb +++ b/lib/seam/deep_hash_accessor.rb @@ -13,6 +13,10 @@ def [](key) instance_variable_get(:"@#{key}") end + def to_h + @data + end + private def create_accessor_methods diff --git a/lib/seam/helpers/action_attempt.rb b/lib/seam/helpers/action_attempt.rb index d00a369d..ba28a7f7 100644 --- a/lib/seam/helpers/action_attempt.rb +++ b/lib/seam/helpers/action_attempt.rb @@ -6,14 +6,23 @@ module Seam module Helpers module ActionAttempt def self.decide_and_wait(action_attempt, client, wait_for_action_attempt) - if wait_for_action_attempt == true - return wait_until_finished(action_attempt, client) - elsif wait_for_action_attempt.is_a?(Hash) - return wait_until_finished(action_attempt, client, timeout: wait_for_action_attempt[:timeout], - polling_interval: wait_for_action_attempt[:polling_interval]) - end + return wait_until_finished(action_attempt, client) if wait_for_action_attempt == true - action_attempt + options = wait_options(wait_for_action_attempt) + return action_attempt if options.nil? + + wait_until_finished(action_attempt, client, timeout: options[:timeout], + polling_interval: options[:polling_interval]) + end + + # The client wraps its defaults in a DeepHashAccessor, so the hash form of + # this option reaches here as an accessor when it comes from the client + # and as a plain Hash when it comes from the method call. + def self.wait_options(wait_for_action_attempt) + case wait_for_action_attempt + when Hash then wait_for_action_attempt + when Seam::DeepHashAccessor then wait_for_action_attempt.to_h + end end def self.wait_until_finished(action_attempt, client, timeout: nil, polling_interval: nil) diff --git a/spec/seam_client/wait_for_action_attempt_spec.rb b/spec/seam_client/wait_for_action_attempt_spec.rb index 82bf86c4..5ce15511 100644 --- a/spec/seam_client/wait_for_action_attempt_spec.rb +++ b/spec/seam_client/wait_for_action_attempt_spec.rb @@ -42,11 +42,6 @@ def update_action_attempt(action_attempt, attributes) end it "can be set to a hash of options on the client" do - # SingleWorkspace wraps its defaults in a DeepHashAccessor, so the hash form - # of this option no longer satisfies the is_a?(Hash) check in - # Helpers::ActionAttempt.decide_and_wait and the client never waits. - pending "the hash form of the client default is wrapped in a DeepHashAccessor" - seam = Seam.new( api_key: seed["seam_apikey1_token"], endpoint: endpoint, From 3df2199cddfde363318a0f5bc2b6ab12db3409f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:14:21 +0000 Subject: [PATCH 5/5] fix: wait for action attempts by default in the from_* constructors Seam.from_api_key and Seam.from_personal_access_token defaulted wait_for_action_attempt to false while Seam.new defaulted it to true, so which constructor was used silently decided whether unlock_door returned a resolved action attempt or a pending one. The README already documents the option as enabled by default, and the other Seam SDKs enable it by default, so the false defaults in the from_* constructors were an oversight rather than an intended difference. Default the option to true in Seam, Seam::Http, and Seam::Http::SingleWorkspace so every constructor agrees. Callers who want the old behavior can still pass wait_for_action_attempt: false. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DzapiU8A9NMdyoTybL9xB --- lib/seam.rb | 4 ++-- lib/seam/http.rb | 4 ++-- lib/seam/http_single_workspace.rb | 4 ++-- spec/seam_client/defaults_spec.rb | 16 ++++++++++++--- .../wait_for_action_attempt_spec.rb | 20 +++++++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/lib/seam.rb b/lib/seam.rb index 6b8d3079..f958acb4 100644 --- a/lib/seam.rb +++ b/lib/seam.rb @@ -11,11 +11,11 @@ def self.new(**args) Seam::Http.new(**args) end - def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: false) + def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: true) Seam::Http.from_api_key(api_key, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt) end - def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: false) + def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: true) Seam::Http.from_personal_access_token(personal_access_token, workspace_id, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt) end diff --git a/lib/seam/http.rb b/lib/seam/http.rb index 658d9848..67c1a47a 100644 --- a/lib/seam/http.rb +++ b/lib/seam/http.rb @@ -8,11 +8,11 @@ def self.new(**args) Http::SingleWorkspace.new(**args) end - def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: false) + def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: true) Http::SingleWorkspace.from_api_key(api_key, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt) end - def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: false) + def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: true) Http::SingleWorkspace.from_personal_access_token(personal_access_token, workspace_id, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt) end diff --git a/lib/seam/http_single_workspace.rb b/lib/seam/http_single_workspace.rb index e7e693cd..58466196 100644 --- a/lib/seam/http_single_workspace.rb +++ b/lib/seam/http_single_workspace.rb @@ -42,12 +42,12 @@ def create_paginator(request, params = {}) Paginator.new(request, params) end - def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: false, faraday_options: {}, faraday_retry_options: {}) + def self.from_api_key(api_key, endpoint: nil, wait_for_action_attempt: true, faraday_options: {}, faraday_retry_options: {}) new(api_key: api_key, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt, faraday_options: faraday_options, faraday_retry_options: faraday_retry_options) end - def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: false, faraday_options: {}, faraday_retry_options: {}) + def self.from_personal_access_token(personal_access_token, workspace_id, endpoint: nil, wait_for_action_attempt: true, faraday_options: {}, faraday_retry_options: {}) new(personal_access_token: personal_access_token, workspace_id: workspace_id, endpoint: endpoint, wait_for_action_attempt: wait_for_action_attempt, faraday_options: faraday_options, faraday_retry_options: faraday_retry_options) end diff --git a/spec/seam_client/defaults_spec.rb b/spec/seam_client/defaults_spec.rb index bb4a4240..b9cc77cc 100644 --- a/spec/seam_client/defaults_spec.rb +++ b/spec/seam_client/defaults_spec.rb @@ -14,11 +14,21 @@ expect(seam.defaults.wait_for_action_attempt).to be false end - # Seam.from_api_key defaults wait_for_action_attempt to false while Seam.new - # defaults it to true. The JavaScript SDK waits by default in both cases. - it "does not wait when constructed with from_api_key" do + it "waits for action attempts when constructed with from_api_key" do seam = Seam.from_api_key("seam_some_api_key") + expect(seam.defaults.wait_for_action_attempt).to be true + end + + it "waits for action attempts when constructed with from_personal_access_token" do + seam = Seam.from_personal_access_token("seam_at_token", "workspace_123") + + expect(seam.defaults.wait_for_action_attempt).to be true + end + + it "can be turned off with from_api_key" do + seam = Seam.from_api_key("seam_some_api_key", wait_for_action_attempt: false) + expect(seam.defaults.wait_for_action_attempt).to be false end end diff --git a/spec/seam_client/wait_for_action_attempt_spec.rb b/spec/seam_client/wait_for_action_attempt_spec.rb index 5ce15511..a0ff141e 100644 --- a/spec/seam_client/wait_for_action_attempt_spec.rb +++ b/spec/seam_client/wait_for_action_attempt_spec.rb @@ -32,6 +32,26 @@ def update_action_attempt(action_attempt, attributes) expect(action_attempt.status).to eq("success") end + it "waits by default when built with from_api_key" do + seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint: endpoint) + + action_attempt = seam.locks.unlock_door(device_id: seed["august_device_1"]) + + expect(action_attempt.status).to eq("success") + end + + it "waits by default when built with from_personal_access_token" do + seam = Seam.from_personal_access_token( + seed["seam_at1_token"], + seed["seed_workspace_1"], + endpoint: endpoint + ) + + action_attempt = seam.locks.unlock_door(device_id: seed["august_device_1"]) + + expect(action_attempt.status).to eq("success") + end + it "waits when set on the method call" do action_attempt = seam.locks.unlock_door( device_id: seed["august_device_1"],