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.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/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/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 e0756656..58466196 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 @@ -37,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/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/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/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/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 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..b9cc77cc --- /dev/null +++ b/spec/seam_client/defaults_spec.rb @@ -0,0 +1,44 @@ +# 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 + + 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 + + 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..6e7207d1 100644 --- a/spec/seam_client/faraday_options_spec.rb +++ b/spec/seam_client/faraday_options_spec.rb @@ -1,46 +1,65 @@ # 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"}} + ) + + 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 + seam = described_class.new( + client: described_class.new( + api_key: seed["seam_apikey1_token"], + endpoint: endpoint + ).client ) - 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.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..a0ff141e --- /dev/null +++ b/spec/seam_client/wait_for_action_attempt_spec.rb @@ -0,0 +1,170 @@ +# 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 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"], + 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 + 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