diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..b3670ad --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,35 @@ +# 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.4'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-version: '2.6.5' + - name: Install dependencies + run: bundle install + - name: Run tests + run: bundle exec rake 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 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' 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 1b9ecce..23fd16c 100644 --- a/lib/dialpad/call.rb +++ b/lib/dialpad/call.rb @@ -1,48 +1,46 @@ module Dialpad class Call < DialpadObject - class RequiredAttributeError < StandardError; end + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( - date_started call_id - state - direction - external_number - internal_number - date_rang - date_first_rang - date_queued - target_availability_status + call_recording_ids callback_requested + contact + csat_score date_connected date_ended - talk_time - hold_time + date_first_rang + date_queued + 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 + hold_time + internal_number + integrations is_transferred - csat_score - routing_breadcrumbs - event_timestamp - mos_score labels - was_recorded + 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 - call_recording_ids - transcription_text - recording_details - integrations - controller - action + was_recorded ).freeze class << self @@ -59,17 +57,10 @@ def retrieve(id = nil) # https://developers.dialpad.com/reference/calllist def list(params = {}) data = Dialpad.client.get('call', params) + return [] if data['items'].nil? || data['items'].empty? 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..ac5e74e 100644 --- a/lib/dialpad/contact.rb +++ b/lib/dialpad/contact.rb @@ -1,6 +1,6 @@ module Dialpad - class Contact - class RequiredAttributeError < StandardError; end + class Contact < DialpadObject + class RequiredAttributeError < Dialpad::DialpadObject::RequiredAttributeError; end ATTRIBUTES = %i( company_name @@ -27,40 +27,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'].nil? || data['items'].empty? + + 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/dialpad_object.rb b/lib/dialpad/dialpad_object.rb index da089d0..2da964d 100644 --- a/lib/dialpad/dialpad_object.rb +++ b/lib/dialpad/dialpad_object.rb @@ -1,13 +1,22 @@ module Dialpad class DialpadObject + 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 2628ac6..07e4076 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 @@ -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'].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 441a53d..a7cf035 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 @@ -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'].nil? || data['items'].empty? + data['items'].map { |item| new(item) } end diff --git a/lib/dialpad/webhook.rb b/lib/dialpad/webhook.rb index 9be7e88..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,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'].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