From 0bddfc281fd2b0ae5725f6545108a8dd879fdeb7 Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Wed, 1 Oct 2025 10:53:06 +0200 Subject: [PATCH 1/7] handle empty data['items'] --- lib/dialpad/call.rb | 9 +------- lib/dialpad/contact.rb | 26 ++++++++++++++++------ lib/dialpad/subscriptions/call_event.rb | 2 ++ lib/dialpad/subscriptions/contact_event.rb | 2 ++ lib/dialpad/webhook.rb | 2 ++ 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/lib/dialpad/call.rb b/lib/dialpad/call.rb index 1b9ecce..71a3119 100644 --- a/lib/dialpad/call.rb +++ b/lib/dialpad/call.rb @@ -59,17 +59,10 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/calllist def list(params = {}) data = Dialpad.client.get('call', params) + return [] if data['items'].blank? data['items'].map { |item| new(item) } end - - private - - def from_hash(hash) - symbolized = hash.respond_to?(:to_h) ? hash.to_h.transform_keys(&:to_sym) : hash - attrs = ATTRIBUTES.filter_map { |key| [key, symbolized[key]] if symbolized.key?(key) }.to_h - new(attrs) - end end end end diff --git a/lib/dialpad/contact.rb b/lib/dialpad/contact.rb index b9e9718..26820d3 100644 --- a/lib/dialpad/contact.rb +++ b/lib/dialpad/contact.rb @@ -1,5 +1,5 @@ module Dialpad - class Contact + class Contact < DialpadObject class RequiredAttributeError < StandardError; end ATTRIBUTES = %i( @@ -20,6 +20,10 @@ class RequiredAttributeError < StandardError; end urls ).freeze + def persisted? + attributes[:id].present? + end + class << self include Validations @@ -27,40 +31,48 @@ class << self def retrieve(id = nil) validate_required_attribute(id, "ID") - Dialpad.client.get("contacts/#{id}") + data = Dialpad.client.get("contacts/#{id}") + new(data) end # https://developers.dialpad.com/reference/contactslist def list(params = {}) - Dialpad.client.get('contacts', params) + data = Dialpad.client.get('contacts', params) + return [] if data['items'].blank? + + data['items'].map { |item| new(item) } end # https://developers.dialpad.com/reference/contactscreate def create(attributes = {}) validate_required_attributes(attributes, %i(first_name last_name)) - Dialpad.client.post('contacts', attributes) + data = Dialpad.client.post('contacts', attributes) + new(data) end # https://developers.dialpad.com/reference/contactscreate_with_uid def create_or_update(attributes = {}) validate_required_attributes(attributes, %i(first_name last_name uid)) - Dialpad.client.put('contacts', attributes) + data = Dialpad.client.put('contacts', attributes) + new(data) end # https://developers.dialpad.com/reference/contactsupdate def update(id = nil, attributes = {}) validate_required_attribute(id, "ID") - Dialpad.client.patch("contacts/#{id}", attributes) + data = Dialpad.client.patch("contacts/#{id}", attributes) + new(data) end # https://developers.dialpad.com/reference/contactsdelete def destroy(id = nil) validate_required_attribute(id, "ID") - Dialpad.client.delete("contacts/#{id}") + data = Dialpad.client.delete("contacts/#{id}") + new(data) end end end diff --git a/lib/dialpad/subscriptions/call_event.rb b/lib/dialpad/subscriptions/call_event.rb index 2628ac6..0dddac4 100644 --- a/lib/dialpad/subscriptions/call_event.rb +++ b/lib/dialpad/subscriptions/call_event.rb @@ -25,6 +25,8 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhook_call_event_subscriptionlist def list(params = {}) data = Dialpad.client.get('subscriptions/call', params) + return [] if data['items'].blank? + data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/subscriptions/contact_event.rb b/lib/dialpad/subscriptions/contact_event.rb index 441a53d..33e7933 100644 --- a/lib/dialpad/subscriptions/contact_event.rb +++ b/lib/dialpad/subscriptions/contact_event.rb @@ -25,6 +25,8 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhook_contact_event_subscriptionlist def list(params = {}) data = Dialpad.client.get('subscriptions/contact', params) + return [] if data['items'].blank? + data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/webhook.rb b/lib/dialpad/webhook.rb index 9be7e88..7c4fd6c 100644 --- a/lib/dialpad/webhook.rb +++ b/lib/dialpad/webhook.rb @@ -22,6 +22,8 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhookslist def list(params = {}) data = Dialpad.client.get('webhooks', params) + return [] if data['items'].blank? + data['items'].map { |item| new(item) } end From 4ed3edfe697c35fc6cd40867c4d44b13fdb1e3f4 Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Wed, 1 Oct 2025 18:30:31 +0200 Subject: [PATCH 2/7] Refactor error handling and attribute definitions across Dialpad classes. Moved RequiredAttributeError to inherit from more specific parent classes and updated ATTRIBUTES in Call class to include new fields while removing deprecated ones. --- lib/dialpad.rb | 11 +++-- lib/dialpad/call.rb | 55 ++++++++++------------ lib/dialpad/contact.rb | 2 +- lib/dialpad/dialpad_object.rb | 2 + lib/dialpad/subscriptions/call_event.rb | 2 +- lib/dialpad/subscriptions/contact_event.rb | 2 +- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/dialpad.rb b/lib/dialpad.rb index 850684b..ea8911f 100644 --- a/lib/dialpad.rb +++ b/lib/dialpad.rb @@ -1,4 +1,11 @@ require 'dialpad/version' + +module Dialpad + class Error < StandardError; end + class ConfigurationError < Error; end + class APIError < Error; end +end + require 'dialpad/client' require 'dialpad/validations' require 'dialpad/dialpad_object' @@ -9,10 +16,6 @@ require 'dialpad/call' module Dialpad - class Error < StandardError; end - class ConfigurationError < Error; end - class APIError < Error; end - class << self attr_writer :base_url, :token diff --git a/lib/dialpad/call.rb b/lib/dialpad/call.rb index 71a3119..c468bdb 100644 --- a/lib/dialpad/call.rb +++ b/lib/dialpad/call.rb @@ -3,46 +3,41 @@ class Call < DialpadObject class RequiredAttributeError < StandardError; end ATTRIBUTES = %i( - date_started + admin_call_recording_share_links call_id - state - direction - external_number - internal_number - date_rang - date_first_rang - date_queued - target_availability_status - callback_requested + call_recording_share_links + contact + csat_recording_urls + csat_score + csat_transcriptions + custom_data date_connected date_ended - talk_time - hold_time + date_rang + date_started + direction duration - total_duration - contact - target entry_point_call_id entry_point_target - operator_call_id - proxy_target + event_timestamp + external_number group_id - master_call_id + internal_number is_transferred - csat_score - routing_breadcrumbs - event_timestamp - mos_score labels - was_recorded - voicemail_link - voicemail_recording_id - call_recording_ids - transcription_text + master_call_id + mos_score + operator_call_id + proxy_target recording_details - integrations - controller - action + routing_breadcrumbs + screen_recording_urls + state + target + total_duration + transcription_text + voicemail_share_link + was_recorded ).freeze class << self diff --git a/lib/dialpad/contact.rb b/lib/dialpad/contact.rb index 26820d3..9e5777a 100644 --- a/lib/dialpad/contact.rb +++ b/lib/dialpad/contact.rb @@ -1,6 +1,6 @@ module Dialpad class Contact < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( company_name diff --git a/lib/dialpad/dialpad_object.rb b/lib/dialpad/dialpad_object.rb index da089d0..15e86a6 100644 --- a/lib/dialpad/dialpad_object.rb +++ b/lib/dialpad/dialpad_object.rb @@ -1,5 +1,7 @@ module Dialpad class DialpadObject + class RequiredAttributeError < Dialpad::APIError; end + attr_reader :attributes def initialize(attributes = {}) diff --git a/lib/dialpad/subscriptions/call_event.rb b/lib/dialpad/subscriptions/call_event.rb index 0dddac4..b87471e 100644 --- a/lib/dialpad/subscriptions/call_event.rb +++ b/lib/dialpad/subscriptions/call_event.rb @@ -1,7 +1,7 @@ module Dialpad module Subscriptions class CallEvent < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( call_states diff --git a/lib/dialpad/subscriptions/contact_event.rb b/lib/dialpad/subscriptions/contact_event.rb index 33e7933..607d8b8 100644 --- a/lib/dialpad/subscriptions/contact_event.rb +++ b/lib/dialpad/subscriptions/contact_event.rb @@ -1,7 +1,7 @@ module Dialpad module Subscriptions class ContactEvent < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( contact_type From 73c3cf48e8a51375e8b0788106491f29a9730d32 Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Thu, 2 Oct 2025 14:16:34 +0200 Subject: [PATCH 3/7] Add specs --- lib/dialpad/call.rb | 21 +- lib/dialpad/contact.rb | 6 +- lib/dialpad/dialpad_object.rb | 11 +- lib/dialpad/subscriptions/call_event.rb | 2 +- lib/dialpad/subscriptions/contact_event.rb | 2 +- lib/dialpad/webhook.rb | 4 +- spec/dialpad/call_spec.rb | 554 ++++++++++++++++++ spec/dialpad/client_spec.rb | 20 +- spec/dialpad/contact_spec.rb | 449 ++++++++++++++ spec/dialpad/subscriptions/call_event_spec.rb | 493 ++++++++++++++++ .../subscriptions/contact_event_spec.rb | 477 +++++++++++++++ spec/dialpad/webhook_spec.rb | 468 +++++++++++++-- spec/validations.rb | 9 - 13 files changed, 2413 insertions(+), 103 deletions(-) create mode 100644 spec/dialpad/call_spec.rb create mode 100644 spec/dialpad/contact_spec.rb create mode 100644 spec/dialpad/subscriptions/call_event_spec.rb create mode 100644 spec/dialpad/subscriptions/contact_event_spec.rb delete mode 100644 spec/validations.rb diff --git a/lib/dialpad/call.rb b/lib/dialpad/call.rb index c468bdb..23fd16c 100644 --- a/lib/dialpad/call.rb +++ b/lib/dialpad/call.rb @@ -1,18 +1,17 @@ module Dialpad class Call < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( - admin_call_recording_share_links call_id - call_recording_share_links + call_recording_ids + callback_requested contact - csat_recording_urls csat_score - csat_transcriptions - custom_data date_connected date_ended + date_first_rang + date_queued date_rang date_started direction @@ -22,7 +21,9 @@ class RequiredAttributeError < StandardError; end event_timestamp external_number group_id + hold_time internal_number + integrations is_transferred labels master_call_id @@ -31,12 +32,14 @@ class RequiredAttributeError < StandardError; end proxy_target recording_details routing_breadcrumbs - screen_recording_urls state + talk_time target + target_availability_status total_duration transcription_text - voicemail_share_link + voicemail_link + voicemail_recording_id was_recorded ).freeze @@ -54,7 +57,7 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/calllist def list(params = {}) data = Dialpad.client.get('call', params) - return [] if data['items'].blank? + return [] if data['items'].nil? || data['items'].empty? data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/contact.rb b/lib/dialpad/contact.rb index 9e5777a..ac5e74e 100644 --- a/lib/dialpad/contact.rb +++ b/lib/dialpad/contact.rb @@ -20,10 +20,6 @@ class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; e urls ).freeze - def persisted? - attributes[:id].present? - end - class << self include Validations @@ -38,7 +34,7 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/contactslist def list(params = {}) data = Dialpad.client.get('contacts', params) - return [] if data['items'].blank? + return [] if data['items'].nil? || data['items'].empty? data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/dialpad_object.rb b/lib/dialpad/dialpad_object.rb index 15e86a6..2da964d 100644 --- a/lib/dialpad/dialpad_object.rb +++ b/lib/dialpad/dialpad_object.rb @@ -5,11 +5,18 @@ class RequiredAttributeError < Dialpad::APIError; end attr_reader :attributes def initialize(attributes = {}) - @attributes = attributes.transform_keys(&:to_sym) + @attributes = + attributes.each_with_object({}) do |(key, value), hash| + hash[key.to_sym] = value + end end def method_missing(method, *args) - @attributes.key?(method) ? @attributes[method] : super + if @attributes.key?(method) + @attributes[method] + else + super + end end def respond_to_missing?(method, include_private = false) diff --git a/lib/dialpad/subscriptions/call_event.rb b/lib/dialpad/subscriptions/call_event.rb index b87471e..07e4076 100644 --- a/lib/dialpad/subscriptions/call_event.rb +++ b/lib/dialpad/subscriptions/call_event.rb @@ -25,7 +25,7 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhook_call_event_subscriptionlist def list(params = {}) data = Dialpad.client.get('subscriptions/call', params) - return [] if data['items'].blank? + return [] if data['items'].nil? || data['items'].empty? data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/subscriptions/contact_event.rb b/lib/dialpad/subscriptions/contact_event.rb index 607d8b8..a7cf035 100644 --- a/lib/dialpad/subscriptions/contact_event.rb +++ b/lib/dialpad/subscriptions/contact_event.rb @@ -25,7 +25,7 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhook_contact_event_subscriptionlist def list(params = {}) data = Dialpad.client.get('subscriptions/contact', params) - return [] if data['items'].blank? + return [] if data['items'].nil? || data['items'].empty? data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/webhook.rb b/lib/dialpad/webhook.rb index 7c4fd6c..3e28d4c 100644 --- a/lib/dialpad/webhook.rb +++ b/lib/dialpad/webhook.rb @@ -1,6 +1,6 @@ module Dialpad class Webhook < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( hook_url @@ -22,7 +22,7 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/webhookslist def list(params = {}) data = Dialpad.client.get('webhooks', params) - return [] if data['items'].blank? + return [] if data['items'].nil? || data['items'].empty? data['items'].map { |item| new(item) } end diff --git a/spec/dialpad/call_spec.rb b/spec/dialpad/call_spec.rb new file mode 100644 index 0000000..f9c43bc --- /dev/null +++ b/spec/dialpad/call_spec.rb @@ -0,0 +1,554 @@ +require 'spec_helper' + +RSpec.describe Dialpad::Call do + let(:base_url) { 'https://api.dialpad.com' } + let(:token) { 'test_token' } + let(:client) { Dialpad::Client.new(base_url: base_url, token: token) } + + before do + allow(Dialpad).to receive(:client).and_return(client) + end + + describe 'class methods' do + describe '.retrieve' do + context 'with valid ID' do + let(:call_data) do + { + 'call_id' => 5780678246121472, + 'direction' => 'outbound', + 'state' => 'calling', + 'duration' => nil, + 'date_started' => 1759338815163, + 'date_ended' => nil, + 'date_connected' => nil, + 'date_rang' => nil, + 'date_first_rang' => nil, + 'date_queued' => nil, + 'internal_number' => '+1234567890', + 'external_number' => '+0987654321', + 'was_recorded' => false, + 'transcription_text' => nil, + 'talk_time' => nil, + 'hold_time' => nil, + 'total_duration' => nil, + 'target_availability_status' => 'open', + 'callback_requested' => nil, + 'contact' => { + 'id' => 'shared_contact_pool_Company:1234567890123456_uid_001', + 'type' => 'shared', + 'email' => 'john.doe@example.com', + 'phone' => '+0987654321', + 'name' => 'John Doe' + }, + 'target' => { + 'id' => 1234567890123456, + 'type' => 'user', + 'email' => 'agent.smith@example.com', + 'phone' => '+1234567890', + 'name' => 'Agent Smith', + 'office_id' => 9876543210987654 + }, + 'entry_point_call_id' => nil, + 'entry_point_target' => {}, + 'operator_call_id' => nil, + 'proxy_target' => {}, + 'group_id' => nil, + 'master_call_id' => nil, + 'is_transferred' => false, + 'csat_score' => nil, + 'routing_breadcrumbs' => [], + 'event_timestamp' => 1759338816268, + 'mos_score' => nil, + 'labels' => [], + 'voicemail_link' => nil, + 'voicemail_recording_id' => nil, + 'call_recording_ids' => [], + 'recording_details' => [], + 'integrations' => {} + } + end + + it 'retrieves a call by ID' do + stub_request(:get, "#{base_url}/call/123") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: call_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call = described_class.retrieve('123') + + expect(call).to be_a(described_class) + expect(call.call_id).to eq(5780678246121472) + expect(call.direction).to eq('outbound') + expect(call.state).to eq('calling') + expect(call.duration).to be_nil + expect(call.was_recorded).to be false + expect(call.date_started).to eq(1759338815163) + expect(call.external_number).to eq('+0987654321') + expect(call.internal_number).to eq('+1234567890') + expect(call.target_availability_status).to eq('open') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.retrieve(nil) }.to raise_error( + Dialpad::Call::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.retrieve('') }.to raise_error( + Dialpad::Call::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.list' do + context 'with calls' do + let(:calls_data) do + { + 'items' => [ + { + 'call_id' => 5780678246121472, + 'direction' => 'outbound', + 'state' => 'calling', + 'duration' => nil, + 'date_started' => 1759338815163, + 'internal_number' => '+1234567890', + 'external_number' => '+0987654321', + 'was_recorded' => false, + 'contact' => { + 'id' => 'shared_contact_pool_Company:1234567890123456_uid_001', + 'type' => 'shared', + 'name' => 'John Doe' + }, + 'target' => { + 'id' => 1234567890123456, + 'type' => 'user', + 'name' => 'Agent Smith' + } + }, + { + 'call_id' => 5780678246121473, + 'direction' => 'inbound', + 'state' => 'completed', + 'duration' => 120, + 'date_started' => 1759338815164, + 'internal_number' => '+1234567890', + 'external_number' => '+1111111111', + 'was_recorded' => true, + 'contact' => { + 'id' => 'shared_contact_pool_Company:1234567890123457_uid_002', + 'type' => 'shared', + 'name' => 'Jane Doe' + }, + 'target' => { + 'id' => 1234567890123457, + 'type' => 'user', + 'name' => 'John Smith' + } + } + ] + } + end + + it 'returns an array of calls' do + stub_request(:get, "#{base_url}/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: calls_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + calls = described_class.list + + expect(calls).to be_an(Array) + expect(calls.length).to eq(2) + expect(calls.first).to be_a(described_class) + expect(calls.first.call_id).to eq(5780678246121472) + expect(calls.first.direction).to eq('outbound') + expect(calls.first.state).to eq('calling') + expect(calls.last.call_id).to eq(5780678246121473) + expect(calls.last.direction).to eq('inbound') + expect(calls.last.state).to eq('completed') + end + + it 'passes query parameters to API' do + params = { 'limit' => 10, 'offset' => 0, 'direction' => 'inbound' } + stub_request(:get, "#{base_url}/call") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + query: params + ) + .to_return(status: 200, body: calls_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + described_class.list(params) + # WebMock automatically verifies the request was made with correct params + end + end + + context 'with no calls' do + it 'returns empty array when items is blank' do + stub_request(:get, "#{base_url}/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: { 'items' => [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + calls = described_class.list + + expect(calls).to eq([]) + end + + it 'returns empty array when items is nil' do + stub_request(:get, "#{base_url}/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + calls = described_class.list + + expect(calls).to eq([]) + end + end + end + end + + describe 'instance methods' do + let(:call_attributes) do + { + call_id: 5780678246121472, + direction: 'outbound', + state: 'calling', + duration: nil, + date_started: 1759338815163, + date_ended: nil, + date_connected: nil, + date_rang: nil, + date_first_rang: nil, + date_queued: nil, + internal_number: '+1234567890', + external_number: '+0987654321', + was_recorded: false, + is_transferred: false, + transcription_text: nil, + csat_score: nil, + mos_score: nil, + total_duration: nil, + talk_time: nil, + hold_time: nil, + target_availability_status: 'open', + callback_requested: nil, + group_id: nil, + operator_call_id: nil, + master_call_id: nil, + entry_point_call_id: nil, + labels: [], + routing_breadcrumbs: [], + event_timestamp: 1759338816268, + voicemail_link: nil, + voicemail_recording_id: nil, + call_recording_ids: [], + recording_details: [], + integrations: {}, + contact: { + id: 'shared_contact_pool_Company:1234567890123456_uid_001', + type: 'shared', + email: 'john.doe@example.com', + phone: '+0987654321', + name: 'John Doe' + }, + target: { + id: 1234567890123456, + type: 'user', + email: 'agent.smith@example.com', + phone: '+1234567890', + name: 'Agent Smith', + office_id: 9876543210987654 + }, + entry_point_target: {}, + proxy_target: {} + } + end + + let(:call) { described_class.new(call_attributes) } + + describe '#initialize' do + it 'sets attributes from hash' do + expect(call.call_id).to eq(5780678246121472) + expect(call.direction).to eq('outbound') + expect(call.state).to eq('calling') + expect(call.duration).to be_nil + expect(call.date_started).to eq(1759338815163) + expect(call.date_ended).to be_nil + expect(call.internal_number).to eq('+1234567890') + expect(call.external_number).to eq('+0987654321') + expect(call.was_recorded).to be false + expect(call.is_transferred).to be false + expect(call.transcription_text).to be_nil + expect(call.csat_score).to be_nil + expect(call.mos_score).to be_nil + expect(call.labels).to eq([]) + expect(call.target_availability_status).to eq('open') + expect(call.talk_time).to be_nil + expect(call.hold_time).to be_nil + expect(call.call_recording_ids).to eq([]) + expect(call.voicemail_link).to be_nil + expect(call.contact).to be_a(Hash) + expect(call.target).to be_a(Hash) + end + + it 'converts string keys to symbols' do + call_with_string_keys = described_class.new( + 'call_id' => 5780678246121472, + 'direction' => 'outbound', + 'state' => 'calling' + ) + + expect(call_with_string_keys.call_id).to eq(5780678246121472) + expect(call_with_string_keys.direction).to eq('outbound') + expect(call_with_string_keys.state).to eq('calling') + end + + it 'handles empty attributes' do + empty_call = described_class.new({}) + expect(empty_call.attributes).to eq({}) + end + end + + describe 'attribute access' do + it 'allows access to all defined attributes' do + expect(call).to respond_to(:call_id) + expect(call).to respond_to(:direction) + expect(call).to respond_to(:state) + expect(call).to respond_to(:duration) + expect(call).to respond_to(:date_started) + expect(call).to respond_to(:date_ended) + expect(call).to respond_to(:date_connected) + expect(call).to respond_to(:date_rang) + expect(call).to respond_to(:date_first_rang) + expect(call).to respond_to(:date_queued) + expect(call).to respond_to(:internal_number) + expect(call).to respond_to(:external_number) + expect(call).to respond_to(:was_recorded) + expect(call).to respond_to(:transcription_text) + expect(call).to respond_to(:csat_score) + expect(call).to respond_to(:mos_score) + expect(call).to respond_to(:labels) + expect(call).to respond_to(:talk_time) + expect(call).to respond_to(:hold_time) + expect(call).to respond_to(:target_availability_status) + expect(call).to respond_to(:callback_requested) + expect(call).to respond_to(:call_recording_ids) + expect(call).to respond_to(:voicemail_link) + expect(call).to respond_to(:voicemail_recording_id) + expect(call).to respond_to(:contact) + expect(call).to respond_to(:target) + expect(call).to respond_to(:integrations) + end + + it 'raises NoMethodError for undefined attributes' do + expect { call.undefined_attribute }.to raise_error(NoMethodError) + end + + it 'responds to defined attributes' do + expect(call.respond_to?(:call_id)).to be true + expect(call.respond_to?(:direction)).to be true + expect(call.respond_to?(:state)).to be true + expect(call.respond_to?(:duration)).to be true + end + + it 'does not respond to undefined attributes' do + expect(call.respond_to?(:undefined_attribute)).to be false + end + end + + describe 'call recording attributes' do + let(:call_with_recordings) do + described_class.new( + call_id: 5780678246121472, + was_recorded: true, + call_recording_ids: ['rec_123', 'rec_456'], + voicemail_link: 'https://example.com/voicemail1.mp3', + voicemail_recording_id: 'vm_789', + recording_details: [ + { 'id' => 'rec_123', 'url' => 'https://example.com/recording1.mp3' }, + { 'id' => 'rec_456', 'url' => 'https://example.com/recording2.mp3' } + ] + ) + end + + it 'handles recording data' do + expect(call_with_recordings.was_recorded).to be true + expect(call_with_recordings.call_recording_ids).to eq(['rec_123', 'rec_456']) + expect(call_with_recordings.voicemail_link).to eq('https://example.com/voicemail1.mp3') + expect(call_with_recordings.voicemail_recording_id).to eq('vm_789') + expect(call_with_recordings.recording_details).to be_an(Array) + expect(call_with_recordings.recording_details.length).to eq(2) + end + end + + describe 'CSAT attributes' do + let(:call_with_csat) do + described_class.new( + call_id: 5780678246121472, + csat_score: 4, + mos_score: 4.2 + ) + end + + it 'handles CSAT data' do + expect(call_with_csat.csat_score).to eq(4) + expect(call_with_csat.mos_score).to eq(4.2) + end + end + + describe 'call routing attributes' do + let(:call_with_routing) do + described_class.new( + call_id: 5780678246121472, + routing_breadcrumbs: ['ivr', 'sales_queue', 'agent_123'], + entry_point_target: { 'type' => 'department', 'name' => 'sales' }, + proxy_target: { 'type' => 'agent', 'id' => 'agent_456' }, + target: { + 'id' => 1234567890123456, + 'type' => 'user', + 'name' => 'Agent Smith' + } + ) + end + + it 'handles routing information' do + expect(call_with_routing.routing_breadcrumbs).to eq(['ivr', 'sales_queue', 'agent_123']) + expect(call_with_routing.entry_point_target).to be_a(Hash) + expect(call_with_routing.proxy_target).to be_a(Hash) + expect(call_with_routing.target).to be_a(Hash) + expect(call_with_routing.target['name']).to eq('Agent Smith') + end + end + + describe 'call metadata' do + let(:call_with_metadata) do + described_class.new( + call_id: 5780678246121472, + group_id: 123456789, + operator_call_id: 987654321, + master_call_id: 111222333, + entry_point_call_id: 444555666, + event_timestamp: 1759338816268, + talk_time: 120, + hold_time: 30, + total_duration: 150 + ) + end + + it 'handles call metadata' do + expect(call_with_metadata.group_id).to eq(123456789) + expect(call_with_metadata.operator_call_id).to eq(987654321) + expect(call_with_metadata.master_call_id).to eq(111222333) + expect(call_with_metadata.entry_point_call_id).to eq(444555666) + expect(call_with_metadata.event_timestamp).to eq(1759338816268) + expect(call_with_metadata.talk_time).to eq(120) + expect(call_with_metadata.hold_time).to eq(30) + expect(call_with_metadata.total_duration).to eq(150) + end + end + end + + describe 'constants' do + it 'defines ATTRIBUTES constant' do + expect(described_class::ATTRIBUTES).to be_an(Array) + expect(described_class::ATTRIBUTES).to be_frozen + expect(described_class::ATTRIBUTES).to include( + :call_id, + :direction, + :state, + :duration, + :date_started, + :date_ended, + :internal_number, + :external_number, + :was_recorded, + :transcription_text, + :csat_score, + :mos_score, + :labels, + :talk_time, + :hold_time, + :target_availability_status, + :call_recording_ids, + :voicemail_link, + :contact, + :target, + :integrations + ) + end + + it 'includes all expected call attributes' do + expected_attributes = %i( + call_id + call_recording_ids + callback_requested + contact + csat_score + date_connected + date_ended + date_first_rang + date_queued + date_rang + date_started + direction + duration + entry_point_call_id + entry_point_target + event_timestamp + external_number + group_id + hold_time + internal_number + integrations + is_transferred + labels + master_call_id + mos_score + operator_call_id + proxy_target + recording_details + routing_breadcrumbs + state + talk_time + target + target_availability_status + total_duration + transcription_text + voicemail_link + voicemail_recording_id + was_recorded + ) + + expect(described_class::ATTRIBUTES).to match_array(expected_attributes) + end + end + + describe 'error handling' do + it 'defines RequiredAttributeError' do + expect(Dialpad::Call::RequiredAttributeError).to be < Dialpad::DialpadObject::RequiredAttributeError + end + end + + describe 'API integration' do + context 'when API returns error' do + it 'handles 404 errors gracefully' do + stub_request(:get, "#{base_url}/call/nonexistent") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 404, body: 'Not Found') + + expect { described_class.retrieve('nonexistent') }.to raise_error(Dialpad::APIError, /404 - Not Found/) + end + + it 'handles 401 errors gracefully' do + stub_request(:get, "#{base_url}/call/123") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 401, body: 'Unauthorized') + + expect { described_class.retrieve('123') }.to raise_error(Dialpad::APIError, /401 - Unauthorized/) + end + end + end +end diff --git a/spec/dialpad/client_spec.rb b/spec/dialpad/client_spec.rb index 5fcd1f0..48b9d60 100644 --- a/spec/dialpad/client_spec.rb +++ b/spec/dialpad/client_spec.rb @@ -27,11 +27,11 @@ it 'makes a GET request' do stub_request(:get, "#{base_url}/test") .with(headers: { 'Authorization' => "Bearer #{token}" }) - .to_return(status: 200, body: { 'id' => 1, 'name' => 'test' }.to_json) + .to_return(status: 200, body: { 'id' => 1, 'name' => 'test' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.get('/test') - expect(response.id).to eq(1) - expect(response.name).to eq('test') + expect(response['id']).to eq(1) + expect(response['name']).to eq('test') end end @@ -42,11 +42,11 @@ headers: { 'Authorization' => "Bearer #{token}" }, body: { 'name' => 'test' }.to_json ) - .to_return(status: 201, body: { 'id' => 1, 'name' => 'test' }.to_json) + .to_return(status: 201, body: { 'id' => 1, 'name' => 'test' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.post('/test', { 'name' => 'test' }) - expect(response.id).to eq(1) - expect(response.name).to eq('test') + expect(response['id']).to eq(1) + expect(response['name']).to eq('test') end end @@ -57,11 +57,11 @@ headers: { 'Authorization' => "Bearer #{token}" }, body: { 'name' => 'updated' }.to_json ) - .to_return(status: 200, body: { 'id' => 1, 'name' => 'updated' }.to_json) + .to_return(status: 200, body: { 'id' => 1, 'name' => 'updated' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.put('/test/1', { 'name' => 'updated' }) - expect(response.id).to eq(1) - expect(response.name).to eq('updated') + expect(response['id']).to eq(1) + expect(response['name']).to eq('updated') end end @@ -72,7 +72,7 @@ .to_return(status: 204, body: '') response = client.delete('/test/1') - expect(response).to be_nil + expect(response).to eq('') end end diff --git a/spec/dialpad/contact_spec.rb b/spec/dialpad/contact_spec.rb new file mode 100644 index 0000000..35fd290 --- /dev/null +++ b/spec/dialpad/contact_spec.rb @@ -0,0 +1,449 @@ +require 'spec_helper' + +RSpec.describe Dialpad::Contact do + let(:base_url) { 'https://api.dialpad.com' } + let(:token) { 'test_token' } + let(:client) { Dialpad::Client.new(base_url: base_url, token: token) } + + before do + allow(Dialpad).to receive(:client).and_return(client) + end + + describe 'class methods' do + describe '.retrieve' do + context 'with valid ID' do + let(:contact_data) do + { + 'id' => '123', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'display_name' => 'John Doe', + 'emails' => ['john@example.com'], + 'phones' => ['+1234567890'] + } + end + + it 'retrieves a contact by ID' do + stub_request(:get, "#{base_url}/contacts/123") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: contact_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact = described_class.retrieve('123') + + expect(contact).to be_a(described_class) + expect(contact.id).to eq('123') + expect(contact.first_name).to eq('John') + expect(contact.last_name).to eq('Doe') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.retrieve(nil) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.retrieve('') }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.list' do + context 'with contacts' do + let(:contacts_data) do + { + 'items' => [ + { + 'id' => '123', + 'first_name' => 'John', + 'last_name' => 'Doe' + }, + { + 'id' => '456', + 'first_name' => 'Jane', + 'last_name' => 'Smith' + } + ] + } + end + + it 'returns an array of contacts' do + stub_request(:get, "#{base_url}/contacts") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: contacts_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contacts = described_class.list + + expect(contacts).to be_an(Array) + expect(contacts.length).to eq(2) + expect(contacts.first).to be_a(described_class) + expect(contacts.first.id).to eq('123') + expect(contacts.first.first_name).to eq('John') + end + + it 'passes query parameters' do + params = { 'limit' => 10, 'offset' => 0 } + stub_request(:get, "#{base_url}/contacts") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + query: params + ) + .to_return(status: 200, body: contacts_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + described_class.list(params) + end + end + + context 'with no contacts' do + it 'returns empty array when items is blank' do + stub_request(:get, "#{base_url}/contacts") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: { 'items' => [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + contacts = described_class.list + + expect(contacts).to eq([]) + end + + it 'returns empty array when items is nil' do + stub_request(:get, "#{base_url}/contacts") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + contacts = described_class.list + + expect(contacts).to eq([]) + end + end + end + + describe '.create' do + let(:contact_attributes) do + { + first_name: 'John', + last_name: 'Doe', + emails: ['john@example.com'], + phones: ['+1234567890'] + } + end + + let(:created_contact_data) do + { + 'id' => '123', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'emails' => ['john@example.com'], + 'phones' => ['+1234567890'] + } + end + + context 'with valid attributes' do + it 'creates a new contact' do + stub_request(:post, "#{base_url}/contacts") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: contact_attributes.to_json + ) + .to_return(status: 201, body: created_contact_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact = described_class.create(contact_attributes) + + expect(contact).to be_a(described_class) + expect(contact.id).to eq('123') + expect(contact.first_name).to eq('John') + expect(contact.last_name).to eq('Doe') + end + end + + context 'with missing required attributes' do + it 'raises RequiredAttributeError when first_name is missing' do + attributes = { last_name: 'Doe' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: first_name' + ) + end + + it 'raises RequiredAttributeError when last_name is missing' do + attributes = { first_name: 'John' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: last_name' + ) + end + + it 'raises RequiredAttributeError when both first_name and last_name are missing' do + attributes = { emails: ['john@example.com'] } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: first_name, last_name' + ) + end + + it 'raises RequiredAttributeError when first_name is empty' do + attributes = { first_name: '', last_name: 'Doe' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: first_name' + ) + end + + it 'raises RequiredAttributeError when last_name is nil' do + attributes = { first_name: 'John', last_name: nil } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: last_name' + ) + end + end + end + + describe '.create_or_update' do + let(:contact_attributes) do + { + first_name: 'John', + last_name: 'Doe', + uid: 'john.doe@company.com', + emails: ['john@example.com'] + } + end + + let(:updated_contact_data) do + { + 'id' => '123', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'uid' => '12345', + 'emails' => ['john@example.com'] + } + end + + context 'with valid attributes' do + it 'creates or updates a contact' do + stub_request(:put, "#{base_url}/contacts") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: contact_attributes.to_json + ) + .to_return(status: 200, body: updated_contact_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact = described_class.create_or_update(contact_attributes) + + expect(contact).to be_a(described_class) + expect(contact.id).to eq('123') + expect(contact.first_name).to eq('John') + expect(contact.last_name).to eq('Doe') + expect(contact.uid).to eq('12345') + end + end + + context 'with missing required attributes' do + it 'raises RequiredAttributeError when uid is missing' do + attributes = { first_name: 'John', last_name: 'Doe' } + + expect { described_class.create_or_update(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: uid' + ) + end + + it 'raises RequiredAttributeError when all required attributes are missing' do + attributes = { emails: ['john@example.com'] } + + expect { described_class.create_or_update(attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attributes: first_name, last_name, uid' + ) + end + end + end + + describe '.update' do + let(:update_attributes) do + { + first_name: 'Johnny', + emails: ['johnny@example.com'] + } + end + + let(:updated_contact_data) do + { + 'id' => '123', + 'first_name' => 'Johnny', + 'last_name' => 'Doe', + 'emails' => ['johnny@example.com'] + } + end + + context 'with valid ID and attributes' do + it 'updates a contact' do + stub_request(:patch, "#{base_url}/contacts/123") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: update_attributes.to_json + ) + .to_return(status: 200, body: updated_contact_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact = described_class.update('123', update_attributes) + + expect(contact).to be_a(described_class) + expect(contact.id).to eq('123') + expect(contact.first_name).to eq('Johnny') + expect(contact.emails).to eq(['johnny@example.com']) + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.update(nil, update_attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.update('', update_attributes) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.destroy' do + let(:deleted_contact_data) do + { + 'id' => '123', + 'first_name' => 'John', + 'last_name' => 'Doe' + } + end + + context 'with valid ID' do + it 'deletes a contact' do + stub_request(:delete, "#{base_url}/contacts/123") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: deleted_contact_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact = described_class.destroy('123') + + expect(contact).to be_a(described_class) + expect(contact.id).to eq('123') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.destroy(nil) }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.destroy('') }.to raise_error( + Dialpad::Contact::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + end + + describe 'instance methods' do + let(:contact_attributes) do + { + id: '123', + first_name: 'John', + last_name: 'Doe', + display_name: 'John Doe', + emails: ['john@example.com'], + phones: ['+1234567890'], + company_name: 'Acme Corp', + job_title: 'Developer' + } + end + + let(:contact) { described_class.new(contact_attributes) } + + describe '#initialize' do + it 'sets attributes from hash' do + expect(contact.id).to eq('123') + expect(contact.first_name).to eq('John') + expect(contact.last_name).to eq('Doe') + expect(contact.display_name).to eq('John Doe') + expect(contact.emails).to eq(['john@example.com']) + expect(contact.phones).to eq(['+1234567890']) + expect(contact.company_name).to eq('Acme Corp') + expect(contact.job_title).to eq('Developer') + end + + it 'converts string keys to symbols' do + contact_with_string_keys = described_class.new( + 'id' => '123', + 'first_name' => 'John' + ) + + expect(contact_with_string_keys.id).to eq('123') + expect(contact_with_string_keys.first_name).to eq('John') + end + + it 'handles empty attributes' do + empty_contact = described_class.new({}) + expect(empty_contact.attributes).to eq({}) + end + end + + describe 'attribute access' do + it 'allows access to all defined attributes' do + expect(contact).to respond_to(:id) + expect(contact).to respond_to(:first_name) + expect(contact).to respond_to(:last_name) + expect(contact).to respond_to(:display_name) + expect(contact).to respond_to(:emails) + expect(contact).to respond_to(:phones) + expect(contact).to respond_to(:company_name) + expect(contact).to respond_to(:job_title) + end + + it 'raises NoMethodError for undefined attributes' do + expect { contact.undefined_attribute }.to raise_error(NoMethodError) + end + + it 'responds to defined attributes' do + expect(contact.respond_to?(:first_name)).to be true + expect(contact.respond_to?(:last_name)).to be true + expect(contact.respond_to?(:id)).to be true + end + + it 'does not respond to undefined attributes' do + expect(contact.respond_to?(:undefined_attribute)).to be false + end + end + end + + describe 'constants' do + it 'defines ATTRIBUTES constant' do + expect(described_class::ATTRIBUTES).to be_an(Array) + expect(described_class::ATTRIBUTES).to be_frozen + expect(described_class::ATTRIBUTES).to include(:id, :first_name, :last_name, :emails, :phones) + end + end + + describe 'error handling' do + it 'defines RequiredAttributeError' do + expect(Dialpad::Contact::RequiredAttributeError).to be < Dialpad::DialpadObject::RequiredAttributeError + end + end +end diff --git a/spec/dialpad/subscriptions/call_event_spec.rb b/spec/dialpad/subscriptions/call_event_spec.rb new file mode 100644 index 0000000..ffc5d55 --- /dev/null +++ b/spec/dialpad/subscriptions/call_event_spec.rb @@ -0,0 +1,493 @@ +require 'spec_helper' + +RSpec.describe Dialpad::Subscriptions::CallEvent do + let(:base_url) { 'https://api.dialpad.com' } + let(:token) { 'test_token' } + let(:client) { Dialpad::Client.new(base_url: base_url, token: token) } + + before do + allow(Dialpad).to receive(:client).and_return(client) + end + + describe 'class methods' do + describe '.retrieve' do + context 'with valid ID' do + let(:call_event_data) do + { + 'call_states' => ['calling'], + 'enabled' => true, + 'group_calls_only' => false, + 'id' => '4614441776955392', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'test_secret', + 'type' => 'jwt' + } + } + } + end + + it 'retrieves a call event subscription by ID' do + stub_request(:get, "#{base_url}/subscriptions/call/4614441776955392") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: call_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_event = described_class.retrieve('4614441776955392') + + expect(call_event).to be_a(described_class) + expect(call_event.id).to eq('4614441776955392') + expect(call_event.call_states).to eq(['calling']) + expect(call_event.enabled).to be true + expect(call_event.group_calls_only).to be false + expect(call_event.webhook).to be_a(Hash) + expect(call_event.webhook['hook_url']).to eq('https://example.com/webhooks/dialpad/call') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.retrieve(nil) }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.retrieve('') }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.list' do + context 'with call event subscriptions' do + let(:call_events_data) do + { + 'items' => [ + { + 'call_states' => ['calling'], + 'enabled' => true, + 'group_calls_only' => false, + 'id' => '4614441776955392', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret1', + 'type' => 'jwt' + } + } + }, + { + 'call_states' => ['completed', 'failed'], + 'enabled' => true, + 'group_calls_only' => true, + 'id' => '4614441776955393', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call2', + 'id' => '5159136949157889', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret2', + 'type' => 'jwt' + } + } + } + ] + } + end + + it 'returns an array of call event subscriptions' do + stub_request(:get, "#{base_url}/subscriptions/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: call_events_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_events = described_class.list + + expect(call_events).to be_an(Array) + expect(call_events.length).to eq(2) + expect(call_events.first).to be_a(described_class) + expect(call_events.first.id).to eq('4614441776955392') + expect(call_events.first.call_states).to eq(['calling']) + expect(call_events.last.id).to eq('4614441776955393') + expect(call_events.last.call_states).to eq(['completed', 'failed']) + expect(call_events.last.group_calls_only).to be true + end + + it 'passes query parameters to API' do + params = { 'limit' => 10, 'offset' => 0 } + stub_request(:get, "#{base_url}/subscriptions/call") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + query: params + ) + .to_return(status: 200, body: call_events_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + described_class.list(params) + end + end + + context 'with no call event subscriptions' do + it 'returns empty array when items is blank' do + stub_request(:get, "#{base_url}/subscriptions/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: { 'items' => [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_events = described_class.list + + expect(call_events).to eq([]) + end + + it 'returns empty array when items is nil' do + stub_request(:get, "#{base_url}/subscriptions/call") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_events = described_class.list + + expect(call_events).to eq([]) + end + end + end + + describe '.create' do + let(:call_event_attributes) do + { + webhook_id: '5159136949157888', + call_states: ['calling', 'completed'], + group_calls_only: false + } + end + + let(:created_call_event_data) do + { + 'call_states' => ['calling', 'completed'], + 'enabled' => true, + 'group_calls_only' => false, + 'id' => '4614441776955392', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'generated_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid attributes' do + it 'creates a new call event subscription' do + stub_request(:post, "#{base_url}/subscriptions/call") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: call_event_attributes.to_json + ) + .to_return(status: 201, body: created_call_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_event = described_class.create(call_event_attributes) + + expect(call_event).to be_a(described_class) + expect(call_event.id).to eq('4614441776955392') + expect(call_event.call_states).to eq(['calling', 'completed']) + expect(call_event.enabled).to be true + expect(call_event.group_calls_only).to be false + expect(call_event.webhook).to be_a(Hash) + end + end + + context 'with missing required attributes' do + it 'raises RequiredAttributeError when webhook_id is missing' do + attributes = { call_states: ['calling'] } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + + it 'raises RequiredAttributeError when webhook_id is empty' do + attributes = { webhook_id: '', call_states: ['calling'] } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + + it 'raises RequiredAttributeError when webhook_id is nil' do + attributes = { webhook_id: nil, call_states: ['calling'] } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + end + end + + describe '.update' do + let(:update_attributes) do + { + call_states: ['calling', 'completed', 'failed'], + group_calls_only: true + } + end + + let(:updated_call_event_data) do + { + 'call_states' => ['calling', 'completed', 'failed'], + 'enabled' => true, + 'group_calls_only' => true, + 'id' => '4614441776955392', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'updated_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid ID and attributes' do + it 'updates a call event subscription' do + stub_request(:patch, "#{base_url}/subscriptions/call/4614441776955392") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: update_attributes.to_json + ) + .to_return(status: 200, body: updated_call_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_event = described_class.update('4614441776955392', update_attributes) + + expect(call_event).to be_a(described_class) + expect(call_event.id).to eq('4614441776955392') + expect(call_event.call_states).to eq(['calling', 'completed', 'failed']) + expect(call_event.group_calls_only).to be true + end + end + end + + describe '.destroy' do + let(:deleted_call_event_data) do + { + 'call_states' => ['calling'], + 'enabled' => false, + 'group_calls_only' => false, + 'id' => '4614441776955392', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'deleted_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid ID' do + it 'deletes a call event subscription' do + stub_request(:delete, "#{base_url}/subscriptions/call/4614441776955392") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: deleted_call_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + call_event = described_class.destroy('4614441776955392') + + expect(call_event).to be_a(described_class) + expect(call_event.id).to eq('4614441776955392') + expect(call_event.enabled).to be false + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.destroy(nil) }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.destroy('') }.to raise_error( + Dialpad::Subscriptions::CallEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + end + + describe 'instance methods' do + let(:call_event_attributes) do + { + call_states: ['calling', 'completed'], + enabled: true, + group_calls_only: false, + id: '4614441776955392', + webhook: { + hook_url: 'https://example.com/webhooks/dialpad/call', + id: '5159136949157888', + signature: { + algo: 'HS256', + secret: 'test_secret', + type: 'jwt' + } + } + } + end + + let(:call_event) { described_class.new(call_event_attributes) } + + describe '#initialize' do + it 'sets attributes from hash' do + expect(call_event.call_states).to eq(['calling', 'completed']) + expect(call_event.enabled).to be true + expect(call_event.group_calls_only).to be false + expect(call_event.id).to eq('4614441776955392') + expect(call_event.webhook).to be_a(Hash) + expect(call_event.webhook[:hook_url]).to eq('https://example.com/webhooks/dialpad/call') + expect(call_event.webhook[:signature][:algo]).to eq('HS256') + end + + it 'converts string keys to symbols' do + call_event_with_string_keys = described_class.new( + 'call_states' => ['calling'], + 'enabled' => true, + 'group_calls_only' => false, + 'id' => '4614441776955392' + ) + + expect(call_event_with_string_keys.call_states).to eq(['calling']) + expect(call_event_with_string_keys.enabled).to be true + expect(call_event_with_string_keys.group_calls_only).to be false + expect(call_event_with_string_keys.id).to eq('4614441776955392') + end + + it 'handles empty attributes' do + empty_call_event = described_class.new({}) + expect(empty_call_event.attributes).to eq({}) + end + end + + describe 'attribute access' do + it 'allows access to all defined attributes' do + expect(call_event).to respond_to(:call_states) + expect(call_event).to respond_to(:enabled) + expect(call_event).to respond_to(:group_calls_only) + expect(call_event).to respond_to(:id) + expect(call_event).to respond_to(:webhook) + end + + it 'raises NoMethodError for undefined attributes' do + expect { call_event.undefined_attribute }.to raise_error(NoMethodError) + end + + it 'responds to defined attributes' do + expect(call_event.respond_to?(:call_states)).to be true + expect(call_event.respond_to?(:enabled)).to be true + expect(call_event.respond_to?(:group_calls_only)).to be true + expect(call_event.respond_to?(:id)).to be true + expect(call_event.respond_to?(:webhook)).to be true + end + + it 'does not respond to undefined attributes' do + expect(call_event.respond_to?(:undefined_attribute)).to be false + end + end + + describe 'call states handling' do + let(:call_event_with_states) do + described_class.new( + call_states: ['calling', 'completed', 'failed', 'ringing'], + enabled: true, + group_calls_only: false, + id: '4614441776955392' + ) + end + + it 'handles multiple call states' do + expect(call_event_with_states.call_states).to eq(['calling', 'completed', 'failed', 'ringing']) + expect(call_event_with_states.call_states.length).to eq(4) + end + end + + describe 'webhook handling' do + let(:call_event_with_webhook) do + described_class.new( + call_states: ['calling'], + enabled: true, + group_calls_only: false, + id: '4614441776955392', + webhook: { + hook_url: 'https://example.com/webhooks/dialpad/call', + id: '5159136949157888', + signature: { + algo: 'HS256', + secret: 'my_secret_key', + type: 'jwt' + } + } + ) + end + + it 'handles webhook object' do + expect(call_event_with_webhook.webhook).to be_a(Hash) + expect(call_event_with_webhook.webhook[:hook_url]).to eq('https://example.com/webhooks/dialpad/call') + expect(call_event_with_webhook.webhook[:id]).to eq('5159136949157888') + expect(call_event_with_webhook.webhook[:signature]).to be_a(Hash) + expect(call_event_with_webhook.webhook[:signature][:algo]).to eq('HS256') + end + end + end + + describe 'constants' do + it 'defines ATTRIBUTES constant' do + expect(described_class::ATTRIBUTES).to be_an(Array) + expect(described_class::ATTRIBUTES).to be_frozen + expect(described_class::ATTRIBUTES).to include(:call_states, :enabled, :group_calls_only, :id, :webhook) + end + + it 'includes all expected call event attributes' do + expected_attributes = %i(call_states enabled group_calls_only id webhook) + expect(described_class::ATTRIBUTES).to match_array(expected_attributes) + end + end + + describe 'error handling' do + it 'defines RequiredAttributeError' do + expect(Dialpad::Subscriptions::CallEvent::RequiredAttributeError).to be < Dialpad::DialpadObject::RequiredAttributeError + end + end + + describe 'API integration' do + context 'when API returns error' do + it 'handles 404 errors gracefully' do + stub_request(:get, "#{base_url}/subscriptions/call/nonexistent") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 404, body: 'Not Found') + + expect { described_class.retrieve('nonexistent') }.to raise_error(Dialpad::APIError, /404 - Not Found/) + end + + it 'handles 401 errors gracefully' do + stub_request(:get, "#{base_url}/subscriptions/call/4614441776955392") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 401, body: 'Unauthorized') + + expect { described_class.retrieve('4614441776955392') }.to raise_error(Dialpad::APIError, /401 - Unauthorized/) + end + end + end +end diff --git a/spec/dialpad/subscriptions/contact_event_spec.rb b/spec/dialpad/subscriptions/contact_event_spec.rb new file mode 100644 index 0000000..a9a11f6 --- /dev/null +++ b/spec/dialpad/subscriptions/contact_event_spec.rb @@ -0,0 +1,477 @@ +require 'spec_helper' + +RSpec.describe Dialpad::Subscriptions::ContactEvent do + let(:base_url) { 'https://api.dialpad.com' } + let(:token) { 'test_token' } + let(:client) { Dialpad::Client.new(base_url: base_url, token: token) } + + before do + allow(Dialpad).to receive(:client).and_return(client) + end + + describe 'class methods' do + describe '.retrieve' do + context 'with valid ID' do + let(:contact_event_data) do + { + 'contact_type' => 'shared', + 'enabled' => true, + 'id' => '5923790016724992', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5428295198556160', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'test_secret', + 'type' => 'jwt' + } + } + } + end + + it 'retrieves a contact event subscription by ID' do + stub_request(:get, "#{base_url}/subscriptions/contact/5923790016724992") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: contact_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_event = described_class.retrieve('5923790016724992') + + expect(contact_event).to be_a(described_class) + expect(contact_event.id).to eq('5923790016724992') + expect(contact_event.contact_type).to eq('shared') + expect(contact_event.enabled).to be true + expect(contact_event.webhook).to be_a(Hash) + expect(contact_event.webhook['hook_url']).to eq('https://example.com/webhooks/dialpad/contact') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.retrieve(nil) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.retrieve('') }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.list' do + context 'with contact event subscriptions' do + let(:contact_events_data) do + { + 'items' => [ + { + 'contact_type' => 'shared', + 'enabled' => true, + 'id' => '5923790016724992', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5428295198556160', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret1', + 'type' => 'jwt' + } + } + }, + { + 'contact_type' => 'user', + 'enabled' => true, + 'id' => '5923790016724993', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact2', + 'id' => '5428295198556161', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret2', + 'type' => 'jwt' + } + } + } + ] + } + end + + it 'returns an array of contact event subscriptions' do + stub_request(:get, "#{base_url}/subscriptions/contact") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: contact_events_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_events = described_class.list + + expect(contact_events).to be_an(Array) + expect(contact_events.length).to eq(2) + expect(contact_events.first).to be_a(described_class) + expect(contact_events.first.id).to eq('5923790016724992') + expect(contact_events.first.contact_type).to eq('shared') + expect(contact_events.last.id).to eq('5923790016724993') + expect(contact_events.last.contact_type).to eq('user') + end + + it 'passes query parameters to API' do + params = { 'limit' => 10, 'offset' => 0 } + stub_request(:get, "#{base_url}/subscriptions/contact") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + query: params + ) + .to_return(status: 200, body: contact_events_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + described_class.list(params) + end + end + + context 'with no contact event subscriptions' do + it 'returns empty array when items is blank' do + stub_request(:get, "#{base_url}/subscriptions/contact") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: { 'items' => [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_events = described_class.list + + expect(contact_events).to eq([]) + end + + it 'returns empty array when items is nil' do + stub_request(:get, "#{base_url}/subscriptions/contact") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_events = described_class.list + + expect(contact_events).to eq([]) + end + end + end + + describe '.create' do + let(:contact_event_attributes) do + { + webhook_id: '5428295198556160', + contact_type: 'shared' + } + end + + let(:created_contact_event_data) do + { + 'contact_type' => 'shared', + 'enabled' => true, + 'id' => '5923790016724992', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5428295198556160', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'generated_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid attributes' do + it 'creates a new contact event subscription' do + stub_request(:post, "#{base_url}/subscriptions/contact") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: contact_event_attributes.to_json + ) + .to_return(status: 201, body: created_contact_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_event = described_class.create(contact_event_attributes) + + expect(contact_event).to be_a(described_class) + expect(contact_event.id).to eq('5923790016724992') + expect(contact_event.contact_type).to eq('shared') + expect(contact_event.enabled).to be true + expect(contact_event.webhook).to be_a(Hash) + end + end + + context 'with missing required attributes' do + it 'raises RequiredAttributeError when webhook_id is missing' do + attributes = { contact_type: 'shared' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + + it 'raises RequiredAttributeError when webhook_id is empty' do + attributes = { webhook_id: '', contact_type: 'shared' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + + it 'raises RequiredAttributeError when webhook_id is nil' do + attributes = { webhook_id: nil, contact_type: 'shared' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attributes: webhook_id' + ) + end + end + end + + describe '.update' do + let(:update_attributes) do + { + contact_type: 'user', + enabled: false + } + end + + let(:updated_contact_event_data) do + { + 'contact_type' => 'user', + 'enabled' => false, + 'id' => '5923790016724992', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5428295198556160', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'updated_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid ID and attributes' do + it 'updates a contact event subscription' do + stub_request(:patch, "#{base_url}/subscriptions/contact/5923790016724992") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: update_attributes.to_json + ) + .to_return(status: 200, body: updated_contact_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_event = described_class.update('5923790016724992', update_attributes) + + expect(contact_event).to be_a(described_class) + expect(contact_event.id).to eq('5923790016724992') + expect(contact_event.contact_type).to eq('user') + expect(contact_event.enabled).to be false + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.update(nil, update_attributes) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.update('', update_attributes) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + + describe '.destroy' do + let(:deleted_contact_event_data) do + { + 'contact_type' => 'shared', + 'enabled' => false, + 'id' => '5923790016724992', + 'webhook' => { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5428295198556160', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'deleted_secret', + 'type' => 'jwt' + } + } + } + end + + context 'with valid ID' do + it 'deletes a contact event subscription' do + stub_request(:delete, "#{base_url}/subscriptions/contact/5923790016724992") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: deleted_contact_event_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + contact_event = described_class.destroy('5923790016724992') + + expect(contact_event).to be_a(described_class) + expect(contact_event.id).to eq('5923790016724992') + expect(contact_event.enabled).to be false + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.destroy(nil) }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.destroy('') }.to raise_error( + Dialpad::Subscriptions::ContactEvent::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end + end + end + + describe 'instance methods' do + let(:contact_event_attributes) do + { + contact_type: 'shared', + enabled: true, + id: '5923790016724992', + webhook: { + hook_url: 'https://example.com/webhooks/dialpad/contact', + id: '5428295198556160', + signature: { + algo: 'HS256', + secret: 'test_secret', + type: 'jwt' + } + } + } + end + + let(:contact_event) { described_class.new(contact_event_attributes) } + + describe '#initialize' do + it 'sets attributes from hash' do + expect(contact_event.contact_type).to eq('shared') + expect(contact_event.enabled).to be true + expect(contact_event.id).to eq('5923790016724992') + expect(contact_event.webhook).to be_a(Hash) + expect(contact_event.webhook[:hook_url]).to eq('https://example.com/webhooks/dialpad/contact') + expect(contact_event.webhook[:signature][:algo]).to eq('HS256') + end + + it 'converts string keys to symbols' do + contact_event_with_string_keys = described_class.new( + 'contact_type' => 'user', + 'enabled' => true, + 'id' => '5923790016724992' + ) + + expect(contact_event_with_string_keys.contact_type).to eq('user') + expect(contact_event_with_string_keys.enabled).to be true + expect(contact_event_with_string_keys.id).to eq('5923790016724992') + end + + it 'handles empty attributes' do + empty_contact_event = described_class.new({}) + expect(empty_contact_event.attributes).to eq({}) + end + end + + describe 'attribute access' do + it 'allows access to all defined attributes' do + expect(contact_event).to respond_to(:contact_type) + expect(contact_event).to respond_to(:enabled) + expect(contact_event).to respond_to(:id) + expect(contact_event).to respond_to(:webhook) + end + + it 'raises NoMethodError for undefined attributes' do + expect { contact_event.undefined_attribute }.to raise_error(NoMethodError) + end + + it 'responds to defined attributes' do + expect(contact_event.respond_to?(:contact_type)).to be true + expect(contact_event.respond_to?(:enabled)).to be true + expect(contact_event.respond_to?(:id)).to be true + expect(contact_event.respond_to?(:webhook)).to be true + end + + it 'does not respond to undefined attributes' do + expect(contact_event.respond_to?(:undefined_attribute)).to be false + end + end + + describe 'contact type handling' do + let(:contact_event_with_type) do + described_class.new( + contact_type: 'user', + enabled: true, + id: '5923790016724992' + ) + end + + it 'handles different contact types' do + expect(contact_event_with_type.contact_type).to eq('user') + end + end + + describe 'webhook handling' do + let(:contact_event_with_webhook) do + described_class.new( + contact_type: 'shared', + enabled: true, + id: '5923790016724992', + webhook: { + hook_url: 'https://example.com/webhooks/dialpad/contact', + id: '5428295198556160', + signature: { + algo: 'HS256', + secret: 'my_secret_key', + type: 'jwt' + } + } + ) + end + + it 'handles webhook object' do + expect(contact_event_with_webhook.webhook).to be_a(Hash) + expect(contact_event_with_webhook.webhook[:hook_url]).to eq('https://example.com/webhooks/dialpad/contact') + expect(contact_event_with_webhook.webhook[:id]).to eq('5428295198556160') + expect(contact_event_with_webhook.webhook[:signature]).to be_a(Hash) + expect(contact_event_with_webhook.webhook[:signature][:algo]).to eq('HS256') + end + end + end + + describe 'error handling' do + it 'defines RequiredAttributeError' do + expect(Dialpad::Subscriptions::ContactEvent::RequiredAttributeError).to be < Dialpad::DialpadObject::RequiredAttributeError + end + end + + describe 'API integration' do + context 'when API returns error' do + it 'handles 404 errors gracefully' do + stub_request(:get, "#{base_url}/subscriptions/contact/nonexistent") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 404, body: 'Not Found') + + expect { described_class.retrieve('nonexistent') }.to raise_error(Dialpad::APIError, /404 - Not Found/) + end + + it 'handles 401 errors gracefully' do + stub_request(:get, "#{base_url}/subscriptions/contact/5923790016724992") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 401, body: 'Unauthorized') + + expect { described_class.retrieve('5923790016724992') }.to raise_error(Dialpad::APIError, /401 - Unauthorized/) + end + end + end +end diff --git a/spec/dialpad/webhook_spec.rb b/spec/dialpad/webhook_spec.rb index aaadfbd..036ced5 100644 --- a/spec/dialpad/webhook_spec.rb +++ b/spec/dialpad/webhook_spec.rb @@ -1,103 +1,443 @@ -require "spec_helper" +require 'spec_helper' RSpec.describe Dialpad::Webhook do - let(:client) { instance_double(Dialpad::Client) } + let(:base_url) { 'https://api.dialpad.com' } + let(:token) { 'test_token' } + let(:client) { Dialpad::Client.new(base_url: base_url, token: token) } before do allow(Dialpad).to receive(:client).and_return(client) end - describe ".retrieve" do - it "retrieves a webhook by ID" do - response = OpenStruct.new(id: 1, url: "https://example.com/webhook") - expect(client).to receive(:get).with("/webhooks/1").and_return(response) + describe 'class methods' do + describe '.retrieve' do + context 'with valid ID' do + let(:webhook_data) do + { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'test_secret', + 'type' => 'jwt' + } + } + end - result = described_class.retrieve(1) - expect(result.id).to eq(1) - expect(result.url).to eq("https://example.com/webhook") + it 'retrieves a webhook by ID' do + stub_request(:get, "#{base_url}/webhooks/5159136949157888") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: webhook_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhook = described_class.retrieve('5159136949157888') + + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq('5159136949157888') + expect(webhook.hook_url).to eq('https://example.com/webhooks/dialpad/call') + expect(webhook.signature).to be_a(Hash) + expect(webhook.signature['algo']).to eq('HS256') + expect(webhook.signature['secret']).to eq('test_secret') + expect(webhook.signature['type']).to eq('jwt') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.retrieve(nil) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.retrieve('') }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end end - it "raises error when ID is missing" do - expect { described_class.retrieve(nil) }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attribute: ID") + describe '.list' do + context 'with webhooks' do + let(:webhooks_data) do + { + 'items' => [ + { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret1', + 'type' => 'jwt' + } + }, + { + 'hook_url' => 'https://example.com/webhooks/dialpad/contact', + 'id' => '5159136949157889', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'secret2', + 'type' => 'jwt' + } + } + ] + } + end + + it 'returns an array of webhooks' do + stub_request(:get, "#{base_url}/webhooks") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: webhooks_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhooks = described_class.list + + expect(webhooks).to be_an(Array) + expect(webhooks.length).to eq(2) + expect(webhooks.first).to be_a(described_class) + expect(webhooks.first.id).to eq('5159136949157888') + expect(webhooks.first.hook_url).to eq('https://example.com/webhooks/dialpad/call') + expect(webhooks.last.id).to eq('5159136949157889') + expect(webhooks.last.hook_url).to eq('https://example.com/webhooks/dialpad/contact') + end + + it 'passes query parameters to API' do + params = { 'limit' => 10, 'offset' => 0 } + stub_request(:get, "#{base_url}/webhooks") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + query: params + ) + .to_return(status: 200, body: webhooks_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + described_class.list(params) + end + end + + context 'with no webhooks' do + it 'returns empty array when items is blank' do + stub_request(:get, "#{base_url}/webhooks") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: { 'items' => [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhooks = described_class.list + + expect(webhooks).to eq([]) + end + + it 'returns empty array when items is nil' do + stub_request(:get, "#{base_url}/webhooks") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhooks = described_class.list + + expect(webhooks).to eq([]) + end + end end - it "raises error when ID is empty" do - expect { described_class.retrieve("") }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attribute: ID") + describe '.create' do + let(:webhook_attributes) do + { + hook_url: 'https://example.com/webhooks/dialpad/call' + } + end + + let(:created_webhook_data) do + { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'generated_secret', + 'type' => 'jwt' + } + } + end + + context 'with valid attributes' do + it 'creates a new webhook' do + stub_request(:post, "#{base_url}/webhooks") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: webhook_attributes.to_json + ) + .to_return(status: 201, body: created_webhook_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhook = described_class.create(webhook_attributes) + + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq('5159136949157888') + expect(webhook.hook_url).to eq('https://example.com/webhooks/dialpad/call') + expect(webhook.signature).to be_a(Hash) + expect(webhook.signature['algo']).to eq('HS256') + end + end + + context 'with missing required attributes' do + it 'raises RequiredAttributeError when hook_url is missing' do + attributes = {} + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attributes: hook_url' + ) + end + + it 'raises RequiredAttributeError when hook_url is empty' do + attributes = { hook_url: '' } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attributes: hook_url' + ) + end + + it 'raises RequiredAttributeError when hook_url is nil' do + attributes = { hook_url: nil } + + expect { described_class.create(attributes) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attributes: hook_url' + ) + end + end end - end - describe ".list" do - it "lists all webhooks" do - response = [ - OpenStruct.new(id: 1, url: "https://example.com/webhook1"), - OpenStruct.new(id: 2, url: "https://example.com/webhook2") - ] - expect(client).to receive(:get).with("/webhooks", {}).and_return(response) - - result = described_class.list - expect(result.length).to eq(2) - expect(result.first.id).to eq(1) + describe '.update' do + let(:update_attributes) do + { + hook_url: 'https://example.com/webhooks/dialpad/updated' + } + end + + let(:updated_webhook_data) do + { + 'hook_url' => 'https://example.com/webhooks/dialpad/updated', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'updated_secret', + 'type' => 'jwt' + } + } + end + + context 'with valid ID and attributes' do + it 'updates a webhook' do + stub_request(:put, "#{base_url}/webhooks/5159136949157888") + .with( + headers: { 'Authorization' => "Bearer #{token}" }, + body: update_attributes.to_json + ) + .to_return(status: 200, body: updated_webhook_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhook = described_class.update('5159136949157888', update_attributes) + + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq('5159136949157888') + expect(webhook.hook_url).to eq('https://example.com/webhooks/dialpad/updated') + expect(webhook.signature['secret']).to eq('updated_secret') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.update(nil, update_attributes) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.update('', update_attributes) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end end - it "lists webhooks with params" do - params = { limit: 10, offset: 0 } - response = [OpenStruct.new(id: 1)] - expect(client).to receive(:get).with("/webhooks", params).and_return(response) + describe '.destroy' do + let(:deleted_webhook_data) do + { + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'deleted_secret', + 'type' => 'jwt' + } + } + end - result = described_class.list(params) - expect(result.length).to eq(1) + context 'with valid ID' do + it 'deletes a webhook' do + stub_request(:delete, "#{base_url}/webhooks/5159136949157888") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 200, body: deleted_webhook_data.to_json, headers: { 'Content-Type' => 'application/json' }) + + webhook = described_class.destroy('5159136949157888') + + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq('5159136949157888') + expect(webhook.hook_url).to eq('https://example.com/webhooks/dialpad/call') + end + end + + context 'with invalid ID' do + it 'raises RequiredAttributeError when ID is nil' do + expect { described_class.destroy(nil) }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + + it 'raises RequiredAttributeError when ID is empty' do + expect { described_class.destroy('') }.to raise_error( + Dialpad::Webhook::RequiredAttributeError, + 'Missing required attribute: ID' + ) + end + end end end - describe ".create" do - it "creates a new webhook" do - attributes = { hook_url: "https://example.com/webhook", events: ["call.completed"] } - response = OpenStruct.new(id: 1, hook_url: "https://example.com/webhook") - expect(client).to receive(:post).with("/webhooks", attributes).and_return(response) + describe 'instance methods' do + let(:webhook_attributes) do + { + hook_url: 'https://example.com/webhooks/dialpad/call', + id: '5159136949157888', + signature: { + algo: 'HS256', + secret: 'test_secret', + type: 'jwt' + } + } + end + + let(:webhook) { described_class.new(webhook_attributes) } - result = described_class.create(attributes) - expect(result.id).to eq(1) - expect(result.hook_url).to eq("https://example.com/webhook") + describe '#initialize' do + it 'sets attributes from hash' do + expect(webhook.hook_url).to eq('https://example.com/webhooks/dialpad/call') + expect(webhook.id).to eq('5159136949157888') + expect(webhook.signature).to be_a(Hash) + expect(webhook.signature[:algo]).to eq('HS256') + expect(webhook.signature[:secret]).to eq('test_secret') + expect(webhook.signature[:type]).to eq('jwt') + end + + it 'converts string keys to symbols' do + webhook_with_string_keys = described_class.new( + 'hook_url' => 'https://example.com/webhooks/dialpad/call', + 'id' => '5159136949157888', + 'signature' => { + 'algo' => 'HS256', + 'secret' => 'test_secret', + 'type' => 'jwt' + } + ) + + expect(webhook_with_string_keys.hook_url).to eq('https://example.com/webhooks/dialpad/call') + expect(webhook_with_string_keys.id).to eq('5159136949157888') + expect(webhook_with_string_keys.signature).to be_a(Hash) + end + + it 'handles empty attributes' do + empty_webhook = described_class.new({}) + expect(empty_webhook.attributes).to eq({}) + end end - it "raises error when hook_url is missing" do - attributes = { events: ["call.completed"] } - expect { described_class.create(attributes) }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attributes: hook_url") + describe 'attribute access' do + it 'allows access to all defined attributes' do + expect(webhook).to respond_to(:hook_url) + expect(webhook).to respond_to(:id) + expect(webhook).to respond_to(:signature) + end + + it 'raises NoMethodError for undefined attributes' do + expect { webhook.undefined_attribute }.to raise_error(NoMethodError) + end + + it 'responds to defined attributes' do + expect(webhook.respond_to?(:hook_url)).to be true + expect(webhook.respond_to?(:id)).to be true + expect(webhook.respond_to?(:signature)).to be true + end + + it 'does not respond to undefined attributes' do + expect(webhook.respond_to?(:undefined_attribute)).to be false + end end - it "raises error when hook_url is empty" do - attributes = { hook_url: "", events: ["call.completed"] } - expect { described_class.create(attributes) }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attributes: hook_url") + describe 'signature handling' do + let(:webhook_with_signature) do + described_class.new( + hook_url: 'https://example.com/webhooks/dialpad/call', + id: '5159136949157888', + signature: { + algo: 'HS256', + secret: 'my_secret_key', + type: 'jwt' + } + ) + end + + it 'handles signature object' do + expect(webhook_with_signature.signature).to be_a(Hash) + expect(webhook_with_signature.signature[:algo]).to eq('HS256') + expect(webhook_with_signature.signature[:secret]).to eq('my_secret_key') + expect(webhook_with_signature.signature[:type]).to eq('jwt') + end + + it 'allows access to signature properties' do + signature = webhook_with_signature.signature + expect(signature[:algo]).to eq('HS256') + expect(signature[:secret]).to eq('my_secret_key') + expect(signature[:type]).to eq('jwt') + end end end - describe ".update" do - it "updates a webhook" do - attributes = { hook_url: "https://example.com/updated-webhook" } - response = OpenStruct.new(id: 1, hook_url: "https://example.com/updated-webhook") - expect(client).to receive(:put).with("/webhooks/1", attributes).and_return(response) + describe 'constants' do + it 'defines ATTRIBUTES constant' do + expect(described_class::ATTRIBUTES).to be_an(Array) + expect(described_class::ATTRIBUTES).to be_frozen + expect(described_class::ATTRIBUTES).to include(:hook_url, :id, :signature) + end - result = described_class.update(1, attributes) - expect(result.id).to eq(1) - expect(result.hook_url).to eq("https://example.com/updated-webhook") + it 'includes all expected webhook attributes' do + expected_attributes = %i(hook_url id signature) + expect(described_class::ATTRIBUTES).to match_array(expected_attributes) end + end - it "raises error when ID is missing" do - attributes = { hook_url: "https://example.com/webhook" } - expect { described_class.update(nil, attributes) }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attribute: ID") + describe 'error handling' do + it 'defines RequiredAttributeError' do + expect(Dialpad::Webhook::RequiredAttributeError).to be < Dialpad::DialpadObject::RequiredAttributeError end end - describe ".destroy" do - it "deletes a webhook" do - expect(client).to receive(:delete).with("/webhooks/1").and_return(nil) + describe 'API integration' do + context 'when API returns error' do + it 'handles 404 errors gracefully' do + stub_request(:get, "#{base_url}/webhooks/nonexistent") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 404, body: 'Not Found') - result = described_class.destroy(1) - expect(result).to be_nil - end + expect { described_class.retrieve('nonexistent') }.to raise_error(Dialpad::APIError, /404 - Not Found/) + end + + it 'handles 401 errors gracefully' do + stub_request(:get, "#{base_url}/webhooks/5159136949157888") + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 401, body: 'Unauthorized') - it "raises error when ID is missing" do - expect { described_class.destroy(nil) }.to raise_error(Dialpad::Webhook::RequiredAttributeError, "Missing required attribute: ID") + expect { described_class.retrieve('5159136949157888') }.to raise_error(Dialpad::APIError, /401 - Unauthorized/) + end end end end diff --git a/spec/validations.rb b/spec/validations.rb deleted file mode 100644 index c78e831..0000000 --- a/spec/validations.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'spec_helper' - -RSpec.describe 'Validations' do - describe 'validate_required_attributes' do - it 'raises an error if required attributes are missing' do - expect { Dialpad::Subscriptions::CallEvent.retrieve }.to raise_error(Dialpad::Subscriptions::CallEvent::RequiredAttributeError) - end - end -end From 6b63ffccab3e67d8b06d119b4f54917d9cac6f2c Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Thu, 2 Oct 2025 14:24:23 +0200 Subject: [PATCH 4/7] Add GH actions --- .github/workflows/ruby.yml | 33 +++++++++++++++++++++++++++++++++ dialpad.gemspec | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ruby.yml diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..44f564a --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,33 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake +# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby + +name: Ruby + +on: + push: + branches: [ "master" ] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: bundle exec rake diff --git a/dialpad.gemspec b/dialpad.gemspec index cdda1f1..c5dbc7d 100644 --- a/dialpad.gemspec +++ b/dialpad.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'faraday', '~> 2.0' spec.add_dependency 'json', '~> 2.0' - spec.required_ruby_version = '>= 3.4.2' + spec.required_ruby_version = '>= 3.0.0' spec.add_development_dependency 'bundler', '~> 2.0' spec.add_development_dependency 'rake', '~> 13.0' From 85910dfcddcd8c744d80021a74b4dd9f9b5564ed Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Thu, 2 Oct 2025 14:26:28 +0200 Subject: [PATCH 5/7] fx --- .github/workflows/ruby.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 44f564a..6a482a5 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -28,6 +28,8 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + bundler-version: '2.6.5' + - name: Install dependencies + run: bundle install - name: Run tests run: bundle exec rake From caed437e42af07a3a8a90dd4a3a0440a9bd800c8 Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Thu, 2 Oct 2025 14:27:16 +0200 Subject: [PATCH 6/7] fx --- .github/workflows/ruby.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 6a482a5..b3670ad 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4'] + ruby-version: ['3.4'] steps: - uses: actions/checkout@v4 From 8ddad74fb9ff3726c551c94f940543f97939ce8b Mon Sep 17 00:00:00 2001 From: Anatoly Shvets Date: Thu, 2 Oct 2025 14:29:25 +0200 Subject: [PATCH 7/7] fx --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9972c80..adba0ce 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Dialpad Ruby Gem +![Tests](https://github.com/maddale/dialpad-ruby/workflows/Ruby/badge.svg) + A Ruby client for the Dialpad API that provides easy access to webhooks, subscriptions, contacts, and calls with full CRUD operations and comprehensive validation. ## Installation