diff --git a/.claude/skills/pipedrive-v2-migrate.md b/.claude/skills/pipedrive-v2-migrate.md new file mode 100644 index 0000000..b92ab61 --- /dev/null +++ b/.claude/skills/pipedrive-v2-migrate.md @@ -0,0 +1,95 @@ +# Pipedrive v2 Resource Migration + +Migrate a Pipedrive resource from v1 to v2 in the pipedrive.rb gem. + +**Usage:** `/pipedrive-v2-migrate ` +**Example:** `/pipedrive-v2-migrate deal` + +The argument is the singular PascalCase class name (e.g. `Deal`, `Person`, `Pipeline`). + +> **Resources NOT available in v2 (keep on v1):** Notes — stop and inform the user if they request one of these. +If no argument is given, ask the user which resource to migrate. + +--- + +## Step 1 — Read the v1 resource + +Read `lib/pipedrive/.rb` to identify: +- Which CRUD modules are included (Create / Read / Update / Delete) +- Any custom methods beyond standard CRUD (e.g. `find_by_name` on Person/Organization) + +Custom methods will need a v2 equivalent — flag them to the user rather than silently +dropping them. + +## Step 2 — Create `lib/pipedrive/v2/.rb` + +Mirror the pattern of [lib/pipedrive/v2/activity.rb](lib/pipedrive/v2/activity.rb). +Include exactly the same operations that the v1 class has. + +```ruby +# frozen_string_literal: true + +module Pipedrive + module V2 + class Deal < ::Pipedrive::V2::Base + include ::Pipedrive::V2::Operations::Create + include ::Pipedrive::V2::Operations::Read + include ::Pipedrive::V2::Operations::Update + include ::Pipedrive::V2::Operations::Delete + end + end +end +``` + +## Step 3 — Create `spec/lib/pipedrive/v2/_spec.rb` + +Mirror the pattern of [spec/lib/pipedrive/v2/activity_spec.rb](spec/lib/pipedrive/v2/activity_spec.rb). + +The spec **must** cover these cases: + +1. **`#entity_name`** — returns the pluralized snake_case resource name (e.g. `"notes"`) +2. **`#build_url`** — returns `/api/v2/` with no args +3. **`#build_url` with id** — returns `/api/v2//42` +4. **`#build_url` no token** — does NOT include `api_token` as query param +5. **`#connection`** — `x-api-token` header equals the token passed to `.new` +6. **`#create`** — stubs POST to `/api/v2/`, asserts `x-api-token` header, asserts `be_success` +7. **`#find_by_id`** — stubs GET `/api/v2//1`, asserts `x-api-token` header, asserts `be_success` +8. **`#update`** — stubs **PATCH** (not PUT) to `/api/v2//1`, asserts `x-api-token` header, asserts `be_success` +9. **`#delete`** — stubs DELETE to `/api/v2//1`, asserts `x-api-token` header, asserts `be_success` +10. **`#all` cursor pagination** — stubs two pages (first returns `next_cursor: 'abc'`, second returns `next_cursor: nil`), asserts all items are collected + +Use `stub_request` with `headers: { 'x-api-token' => 'token' }` on every stub. +Response bodies must be valid JSON with `success: true` and `data: {}` (or `data: []` for list endpoints). + +## Step 4 — Register the require in `lib/pipedrive.rb` + +Find the existing `require 'pipedrive/'` line and add the v2 require immediately after: + +```ruby +# Deals +require 'pipedrive/deal' +require 'pipedrive/v2/deal' # add this line +``` + +## Step 5 — Update `HISTORY.md` and `lib/pipedrive/version.rb` + +Bump the patch version in `lib/pipedrive/version.rb` and add an entry at the top of `HISTORY.md`: + +```markdown +## [v0.X.Y](https://github.com/hostnfly/pipedrive.rb/compare/v0.X.(Y-1)...v0.X.Y) - YYYY-MM-DD + +### Added +- `Pipedrive::V2::` — endpoint migrated to API v2 +``` + +## Step 6 — Run the specs + +```bash +bundle exec rspec spec/lib/pipedrive/v2/_spec.rb +``` + +Fix any failures before declaring done. Then run the full suite to check for regressions: + +```bash +bundle exec rspec +``` diff --git a/HISTORY.md b/HISTORY.md index 32ed8eb..6519412 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.4.3](https://github.com/hostnfly/pipedrive.rb/compare/v0.4.2...v0.4.3) - 2026-06-18 + +### Added +- `Pipedrive::V2::Deal` — Deals endpoint migrated to API v2 + +## [v0.4.2](https://github.com/hostnfly/pipedrive.rb/compare/v0.4.1...v0.4.2) - 2026-02-27 + +### Fixed +- JSON request body encoding for v2 endpoints + +## [v0.4.1](https://github.com/hostnfly/pipedrive.rb/compare/v0.4.0...v0.4.1) - 2026-02-27 + +### Added +- `Pipedrive::V2::Activity` — Activities endpoint migrated to API v2 + +## [v0.4.0](https://github.com/hostnfly/pipedrive.rb/compare/v0.3.0...v0.4.0) - 2026-02-27 + +### Added +- Pipedrive API v2 infrastructure (`Pipedrive::V2::Base`, `V2::Utils`, `V2::Operations::*`) +- Cursor-based pagination for v2 endpoints +- `x-api-token` header authentication for v2 + ## [v0.3.0](https://github.com/amoniacou/pipedrive.rb/compare/v0.2.0...v0.3.0) - 2020-10-10 ### Merged diff --git a/lib/pipedrive.rb b/lib/pipedrive.rb index bc67dd9..362936d 100644 --- a/lib/pipedrive.rb +++ b/lib/pipedrive.rb @@ -2,6 +2,7 @@ require 'logger' require 'active_support/core_ext/hash' +require 'active_support/core_ext/array' require 'active_support/concern' require 'active_support/inflector' @@ -51,6 +52,14 @@ def logger require 'pipedrive/operations/update' require 'pipedrive/operations/delete' +# Core V2 +require 'pipedrive/v2/utils' +require 'pipedrive/v2/base' +require 'pipedrive/v2/operations/create' +require 'pipedrive/v2/operations/read' +require 'pipedrive/v2/operations/update' +require 'pipedrive/v2/operations/delete' + # Persons require 'pipedrive/person_field' require 'pipedrive/person' @@ -78,10 +87,12 @@ def logger # Activities require 'pipedrive/activity' require 'pipedrive/activity_type' +require 'pipedrive/v2/activity' # Deals require 'pipedrive/deal_field' require 'pipedrive/deal' +require 'pipedrive/v2/deal' # Files require 'pipedrive/file' diff --git a/lib/pipedrive/base.rb b/lib/pipedrive/base.rb index cef4418..95bb191 100644 --- a/lib/pipedrive/base.rb +++ b/lib/pipedrive/base.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module Pipedrive + class RateLimitError < StandardError + attr_reader :response + + def initialize(response) + @response = response + end + end + class Base def initialize(api_token = ::Pipedrive.api_token) raise 'api_token should be set' unless api_token.present? @@ -17,16 +25,13 @@ def make_api_call(*args) method = args[0] raise 'method param missing' unless method.present? - url = build_url(args, params.delete(:fields_to_select)) begin + url = build_url(args, params.delete(:fields_to_select)) res = connection.__send__(method.to_sym, url, params) - rescue Errno::ETIMEDOUT - retry - rescue Faraday::ParsingError - sleep 5 - retry + rescue Faraday::ParsingError => e + res = e.response end - process_response(res) + process_response(res).merge(status: res.status) end def build_url(args, fields_to_select = nil) @@ -50,13 +55,20 @@ def process_response(res) end def failed_response(res) - failed_res = res.body.merge(success: false, not_authorized: false, - failed: false) + failed_res = if res.body.is_a?(::Hashie::Mash) + res.body.merge(success: false, not_authorized: false, failed: false, not_found: false) + else + ::Hashie::Mash.new(success: false, not_authorized: false, failed: false, not_found: false) + end case res.status when 401 failed_res[:not_authorized] = true + when 404 + failed_res[:not_found] = true when 420 failed_res[:failed] = true + when 429 + raise RateLimitError.new(res) end failed_res end @@ -82,7 +94,7 @@ def faraday_options # :nodoc def connection @connection ||= Faraday.new(faraday_options) do |conn| - conn.request :url_encoded + conn.request params_format conn.response :mashify conn.response :json, content_type: /\bjson$/ conn.use FaradayMiddleware::ParseJson @@ -90,6 +102,12 @@ def connection conn.adapter Faraday.default_adapter end end + + private + + def params_format + :url_encoded + end end end end diff --git a/lib/pipedrive/v2/activity.rb b/lib/pipedrive/v2/activity.rb new file mode 100644 index 0000000..8b54c32 --- /dev/null +++ b/lib/pipedrive/v2/activity.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + class Activity < ::Pipedrive::V2::Base + include ::Pipedrive::V2::Operations::Create + include ::Pipedrive::V2::Operations::Read + include ::Pipedrive::V2::Operations::Update + include ::Pipedrive::V2::Operations::Delete + end + end +end diff --git a/lib/pipedrive/v2/base.rb b/lib/pipedrive/v2/base.rb new file mode 100644 index 0000000..e27704b --- /dev/null +++ b/lib/pipedrive/v2/base.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + class Base < ::Pipedrive::Base + class << self + private + + # V2 API requires JSON request bodies to preserve integer types. + def params_format + :json + end + end + + def build_url(args, _fields_to_select = nil) + url = +"/api/v2/#{entity_name}" + url << "/#{args[1]}" if args[1] + url + end + + def connection + conn = self.class.connection.dup + conn.headers['x-api-token'] = @api_token + conn + end + end + end +end diff --git a/lib/pipedrive/v2/deal.rb b/lib/pipedrive/v2/deal.rb new file mode 100644 index 0000000..90d5896 --- /dev/null +++ b/lib/pipedrive/v2/deal.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + class Deal < ::Pipedrive::V2::Base + include ::Pipedrive::V2::Operations::Create + include ::Pipedrive::V2::Operations::Read + include ::Pipedrive::V2::Operations::Update + include ::Pipedrive::V2::Operations::Delete + end + end +end diff --git a/lib/pipedrive/v2/operations/create.rb b/lib/pipedrive/v2/operations/create.rb new file mode 100644 index 0000000..f9f66af --- /dev/null +++ b/lib/pipedrive/v2/operations/create.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + module Operations + module Create + extend ActiveSupport::Concern + + def create(params) + make_api_call(:post, params) + end + end + end + end +end diff --git a/lib/pipedrive/v2/operations/delete.rb b/lib/pipedrive/v2/operations/delete.rb new file mode 100644 index 0000000..fc1f0b9 --- /dev/null +++ b/lib/pipedrive/v2/operations/delete.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + module Operations + module Delete + extend ActiveSupport::Concern + + def delete(id) + make_api_call(:delete, id) + end + + def delete_all + make_api_call(:delete) + end + end + end + end +end diff --git a/lib/pipedrive/v2/operations/read.rb b/lib/pipedrive/v2/operations/read.rb new file mode 100644 index 0000000..49eb691 --- /dev/null +++ b/lib/pipedrive/v2/operations/read.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + module Operations + module Read + extend ActiveSupport::Concern + include ::Enumerable + include ::Pipedrive::V2::Utils + + def each(params = {}, &block) + return to_enum(:each, params) unless block_given? + + follow_pagination(:chunk, [], params, &block) + end + + def all(params = {}) + each(params).to_a + end + + def chunk(params = {}) + res = make_api_call(:get, params) + return [] unless res.success? + + res + end + + def find_by_id(id) + make_api_call(:get, id) + end + end + end + end +end diff --git a/lib/pipedrive/v2/operations/update.rb b/lib/pipedrive/v2/operations/update.rb new file mode 100644 index 0000000..7f5a557 --- /dev/null +++ b/lib/pipedrive/v2/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + module Operations + module Update + extend ActiveSupport::Concern + + def update(*args) + params = args.extract_options! + params.symbolize_keys! + id = params.delete(:id) || args[0] + raise 'id must be provided' unless id + + make_api_call(:patch, id, params) + end + end + end + end +end diff --git a/lib/pipedrive/v2/utils.rb b/lib/pipedrive/v2/utils.rb new file mode 100644 index 0000000..25355f2 --- /dev/null +++ b/lib/pipedrive/v2/utils.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Pipedrive + module V2 + module Utils + extend ActiveSupport::Concern + + def follow_pagination(method, args, params, &block) + cursor = nil + loop do + pagination_params = cursor ? params.merge(cursor: cursor) : params + res = __send__(method, *args, pagination_params) + break if !res.try(:data) || !res.success? + + res.data.each(&block) + cursor = res.try(:additional_data).try(:next_cursor) + break if cursor.nil? + end + end + end + end +end diff --git a/lib/pipedrive/version.rb b/lib/pipedrive/version.rb index 5e1797d..433eb1e 100644 --- a/lib/pipedrive/version.rb +++ b/lib/pipedrive/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Pipedrive - VERSION = '0.3.0' + VERSION = '0.4.3' end diff --git a/spec/lib/pipedrive/v2/activity_spec.rb b/spec/lib/pipedrive/v2/activity_spec.rb new file mode 100644 index 0000000..4d612b1 --- /dev/null +++ b/spec/lib/pipedrive/v2/activity_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Activity do + subject { described_class.new('token') } + + describe '#entity_name' do + it { expect(subject.entity_name).to eq('activities') } + end + + describe '#build_url' do + it 'uses /api/v2/activities' do + expect(subject.build_url([])).to eq('/api/v2/activities') + end + + it 'appends id when provided' do + expect(subject.build_url([nil, 42])).to eq('/api/v2/activities/42') + end + + it 'does not include api_token as query param' do + expect(subject.build_url([])).not_to include('api_token') + end + end + + describe '#connection' do + it 'authenticates via x-api-token header' do + expect(subject.connection.headers['x-api-token']).to eq('token') + end + end + + describe '#create' do + it 'POSTs to /api/v2/activities with x-api-token header' do + stub_request(:post, 'https://api.pipedrive.com/api/v2/activities') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.create(subject: 'Call', type: 'call')).to be_success + end + end + + describe '#find_by_id' do + it 'GETs /api/v2/activities/:id with x-api-token header' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/activities/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.find_by_id(1)).to be_success + end + end + + describe '#update' do + it 'PATCHes /api/v2/activities/:id with x-api-token header' do + stub_request(:patch, 'https://api.pipedrive.com/api/v2/activities/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.update(1, subject: 'Updated call')).to be_success + end + end + + describe '#delete' do + it 'DELETEs /api/v2/activities/:id with x-api-token header' do + stub_request(:delete, 'https://api.pipedrive.com/api/v2/activities/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true }.to_json, headers: {}) + expect(subject.delete(1)).to be_success + end + end + + describe '#all' do + it 'uses cursor-based pagination' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/activities') + .with(headers: { 'x-api-token' => 'token' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 1 }, { id: 2 }], additional_data: { next_cursor: 'abc' } }.to_json, + headers: {} + ) + stub_request(:get, 'https://api.pipedrive.com/api/v2/activities') + .with(headers: { 'x-api-token' => 'token' }, query: { 'cursor' => 'abc' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 3 }], additional_data: { next_cursor: nil } }.to_json, + headers: {} + ) + expect(subject.all.length).to eq(3) + end + end + + describe '#each' do + it 'supports filtering by owner_id' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/activities') + .with(headers: { 'x-api-token' => 'token' }, query: { 'owner_id' => '5' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 1 }], additional_data: { next_cursor: nil } }.to_json, + headers: {} + ) + expect { |b| subject.each({ owner_id: 5 }, &b) }.to yield_successive_args(::Hashie::Mash.new(id: 1)) + end + end +end diff --git a/spec/lib/pipedrive/v2/base_spec.rb b/spec/lib/pipedrive/v2/base_spec.rb new file mode 100644 index 0000000..1bd0d51 --- /dev/null +++ b/spec/lib/pipedrive/v2/base_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Base do + subject { described_class.new('token') } + + describe '#entity_name' do + subject { super().entity_name } + + it { is_expected.to eq described_class.name.split('::')[-1].downcase.pluralize } + end + + context '::faraday_options' do + subject { described_class.faraday_options } + + it 'keeps the same base URL as v1' do + expect(subject[:url]).to eq('https://api.pipedrive.com') + end + end + + context '::connection' do + subject { super().connection } + + it { is_expected.to be_kind_of(::Faraday::Connection) } + end + + describe '#connection' do + it 'sets x-api-token header from api_token' do + expect(subject.connection.headers['x-api-token']).to eq('token') + end + + it 'does not pollute the class-level connection headers' do + described_class.new('token_a').connection + described_class.new('token_b').connection + expect(described_class.connection.headers['x-api-token']).to be_nil + end + end + + describe '#build_url' do + it 'uses /api/v2/ prefix' do + expect(subject.build_url([])).to start_with('/api/v2/') + end + + it 'does not include api_token as query param' do + expect(subject.build_url([])).not_to include('api_token') + end + + it 'appends id when provided' do + expect(subject.build_url([nil, 42])).to end_with('/42') + end + + it 'ignores fields_to_select' do + expect(subject.build_url([], %w[a b c])).not_to include(':(') + end + end + + describe '#make_api_call' do + let(:entity) { described_class.name.split('::')[-1].downcase.pluralize } + + it 'fails with no method' do + expect { subject.make_api_call(test: 'foo') }.to raise_error('method param missing') + end + + context 'without id' do + it 'calls :get with v2 path and x-api-token header' do + stub_request(:get, "https://api.pipedrive.com/api/v2/#{entity}") + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: {}.to_json, headers: {}) + expect(subject.make_api_call(:get)).to be_success + end + + it 'calls :post with v2 path' do + stub_request(:post, "https://api.pipedrive.com/api/v2/#{entity}") + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: {}.to_json, headers: {}) + expect(subject.make_api_call(:post, test: 'bar')).to be_success + end + end + + context 'request body encoding' do + it 'sends body as JSON with integer values preserved' do + stub_request(:post, "https://api.pipedrive.com/api/v2/#{entity}") + .with( + headers: { 'Content-Type' => 'application/json' }, + body: { owner_id: 11592426 }.to_json + ) + .to_return(status: 200, body: {}.to_json, headers: {}) + expect(subject.make_api_call(:post, owner_id: 11592426)).to be_success + end + end + + context 'with id' do + it 'calls :get with id in path' do + stub_request(:get, "https://api.pipedrive.com/api/v2/#{entity}/12") + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: {}.to_json, headers: {}) + expect(subject.make_api_call(:get, 12)).to be_success + end + + it 'calls :patch with id in path' do + stub_request(:patch, "https://api.pipedrive.com/api/v2/#{entity}/14") + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: {}.to_json, headers: {}) + expect(subject.make_api_call(:patch, 14, test: 'bar')).to be_success + end + end + end +end diff --git a/spec/lib/pipedrive/v2/deal_spec.rb b/spec/lib/pipedrive/v2/deal_spec.rb new file mode 100644 index 0000000..c99b3ce --- /dev/null +++ b/spec/lib/pipedrive/v2/deal_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Deal do + subject { described_class.new('token') } + + describe '#entity_name' do + it { expect(subject.entity_name).to eq('deals') } + end + + describe '#build_url' do + it 'uses /api/v2/deals' do + expect(subject.build_url([])).to eq('/api/v2/deals') + end + + it 'appends id when provided' do + expect(subject.build_url([nil, 42])).to eq('/api/v2/deals/42') + end + + it 'does not include api_token as query param' do + expect(subject.build_url([])).not_to include('api_token') + end + end + + describe '#connection' do + it 'authenticates via x-api-token header' do + expect(subject.connection.headers['x-api-token']).to eq('token') + end + end + + describe '#create' do + it 'POSTs to /api/v2/deals with x-api-token header' do + stub_request(:post, 'https://api.pipedrive.com/api/v2/deals') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.create(title: 'New deal', owner_id: 1)).to be_success + end + end + + describe '#find_by_id' do + it 'GETs /api/v2/deals/:id with x-api-token header' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/deals/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.find_by_id(1)).to be_success + end + end + + describe '#update' do + it 'PATCHes /api/v2/deals/:id with x-api-token header' do + stub_request(:patch, 'https://api.pipedrive.com/api/v2/deals/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true, data: {} }.to_json, headers: {}) + expect(subject.update(1, title: 'Updated deal')).to be_success + end + end + + describe '#delete' do + it 'DELETEs /api/v2/deals/:id with x-api-token header' do + stub_request(:delete, 'https://api.pipedrive.com/api/v2/deals/1') + .with(headers: { 'x-api-token' => 'token' }) + .to_return(status: 200, body: { success: true }.to_json, headers: {}) + expect(subject.delete(1)).to be_success + end + end + + describe '#all' do + it 'uses cursor-based pagination' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/deals') + .with(headers: { 'x-api-token' => 'token' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 1 }, { id: 2 }], additional_data: { next_cursor: 'abc' } }.to_json, + headers: {} + ) + stub_request(:get, 'https://api.pipedrive.com/api/v2/deals') + .with(headers: { 'x-api-token' => 'token' }, query: { 'cursor' => 'abc' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 3 }], additional_data: { next_cursor: nil } }.to_json, + headers: {} + ) + expect(subject.all.length).to eq(3) + end + end + + describe '#each' do + it 'supports filtering by owner_id' do + stub_request(:get, 'https://api.pipedrive.com/api/v2/deals') + .with(headers: { 'x-api-token' => 'token' }, query: { 'owner_id' => '5' }) + .to_return( + status: 200, + body: { success: true, data: [{ id: 1 }], additional_data: { next_cursor: nil } }.to_json, + headers: {} + ) + expect { |b| subject.each({ owner_id: 5 }, &b) }.to yield_successive_args(::Hashie::Mash.new(id: 1)) + end + end +end diff --git a/spec/lib/pipedrive/v2/operations/create_spec.rb b/spec/lib/pipedrive/v2/operations/create_spec.rb new file mode 100644 index 0000000..75c86b9 --- /dev/null +++ b/spec/lib/pipedrive/v2/operations/create_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Operations::Create do + subject do + Class.new(::Pipedrive::V2::Base) do + include ::Pipedrive::V2::Operations::Create + end.new('token') + end + + describe '#create' do + it 'calls #make_api_call with :post' do + expect(subject).to receive(:make_api_call).with(:post, { foo: 'bar' }) + subject.create(foo: 'bar') + end + end +end diff --git a/spec/lib/pipedrive/v2/operations/delete_spec.rb b/spec/lib/pipedrive/v2/operations/delete_spec.rb new file mode 100644 index 0000000..d25fedc --- /dev/null +++ b/spec/lib/pipedrive/v2/operations/delete_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Operations::Delete do + subject do + Class.new(::Pipedrive::V2::Base) do + include ::Pipedrive::V2::Operations::Delete + end.new('token') + end + + describe '#delete' do + it 'calls #make_api_call with :delete and id' do + expect(subject).to receive(:make_api_call).with(:delete, 12) + subject.delete(12) + end + end + + describe '#delete_all' do + it 'calls #make_api_call with :delete' do + expect(subject).to receive(:make_api_call).with(:delete) + subject.delete_all + end + end +end diff --git a/spec/lib/pipedrive/v2/operations/read_spec.rb b/spec/lib/pipedrive/v2/operations/read_spec.rb new file mode 100644 index 0000000..818f2d3 --- /dev/null +++ b/spec/lib/pipedrive/v2/operations/read_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Operations::Read do + subject do + Class.new(::Pipedrive::V2::Base) do + include ::Pipedrive::V2::Operations::Read + end.new('token') + end + + describe '#find_by_id' do + it 'calls #make_api_call' do + expect(subject).to receive(:make_api_call).with(:get, 12) + subject.find_by_id(12) + end + end + + describe '#each' do + it 'returns Enumerator if no block given' do + expect(subject.each).to be_a(::Enumerator) + end + + it 'calls to_enum with params' do + expect(subject).to receive(:to_enum).with(:each, { foo: 'bar' }) + subject.each(foo: 'bar') + end + + it 'yields data from a single page' do + expect(subject).to receive(:chunk).and_return( + ::Hashie::Mash.new(data: [1, 2], success: true, additional_data: { next_cursor: nil }) + ) + expect { |b| subject.each(&b) }.to yield_successive_args(1, 2) + end + + it 'follows cursor-based pagination' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(data: [1, 2], success: true, additional_data: { next_cursor: 'abc' }) + ) + expect(subject).to receive(:chunk).with(cursor: 'abc').and_return( + ::Hashie::Mash.new(data: [3, 4], success: true, additional_data: { next_cursor: nil }) + ) + expect { |b| subject.each(&b) }.to yield_successive_args(1, 2, 3, 4) + end + + it 'does not yield anything if result has no data' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(success: true) + ) + expect { |b| subject.each(&b) }.to yield_successive_args + end + + it 'does not yield anything if result is not success' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(success: false) + ) + expect { |b| subject.each(&b) }.to yield_successive_args + end + end + + describe '#all' do + it 'calls #each and returns array' do + arr = double('enumerator') + allow(arr).to receive(:to_a) + expect(subject).to receive(:each).and_return(arr) + subject.all + end + end + + describe '#chunk' do + it 'returns [] on failed response' do + res = double('res', success?: false) + expect(subject).to receive(:make_api_call).with(:get, {}).and_return(res) + expect(subject.chunk).to eq([]) + end + + it 'returns result on success' do + res = double('res', success?: true) + expect(subject).to receive(:make_api_call).with(:get, {}).and_return(res) + expect(subject.chunk).to eq(res) + end + end +end diff --git a/spec/lib/pipedrive/v2/operations/update_spec.rb b/spec/lib/pipedrive/v2/operations/update_spec.rb new file mode 100644 index 0000000..2d034ad --- /dev/null +++ b/spec/lib/pipedrive/v2/operations/update_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Operations::Update do + subject do + Class.new(::Pipedrive::V2::Base) do + include ::Pipedrive::V2::Operations::Update + end.new('token') + end + + describe '#update' do + it 'calls #make_api_call with :patch' do + expect(subject).to receive(:make_api_call).with(:patch, 12, { foo: 'bar' }) + subject.update(12, foo: 'bar') + end + + it 'calls #make_api_call with id in params' do + expect(subject).to receive(:make_api_call).with(:patch, 14, { foo: 'bar' }) + subject.update(foo: 'bar', id: 14) + end + + it 'raises when id is missing' do + expect { subject.update(foo: 'bar') }.to raise_error('id must be provided') + end + end +end diff --git a/spec/lib/pipedrive/v2/utils_spec.rb b/spec/lib/pipedrive/v2/utils_spec.rb new file mode 100644 index 0000000..a8d2f64 --- /dev/null +++ b/spec/lib/pipedrive/v2/utils_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Pipedrive::V2::Utils do + subject do + Class.new(::Pipedrive::V2::Base) do + include ::Pipedrive::V2::Utils + end.new('token') + end + + describe '#follow_pagination' do + it 'yields all items from a single page with no next cursor' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(data: [1, 2], success: true, additional_data: { next_cursor: nil }) + ) + results = [] + subject.follow_pagination(:chunk, [], {}) { |item| results << item } + expect(results).to eq([1, 2]) + end + + it 'follows cursor across multiple pages' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(data: [1, 2], success: true, additional_data: { next_cursor: 'abc' }) + ) + expect(subject).to receive(:chunk).with(cursor: 'abc').and_return( + ::Hashie::Mash.new(data: [3, 4], success: true, additional_data: { next_cursor: nil }) + ) + results = [] + subject.follow_pagination(:chunk, [], {}) { |item| results << item } + expect(results).to eq([1, 2, 3, 4]) + end + + it 'stops when response has no data' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(success: true) + ) + results = [] + subject.follow_pagination(:chunk, [], {}) { |item| results << item } + expect(results).to be_empty + end + + it 'stops when response is not successful' do + expect(subject).to receive(:chunk).with({}).and_return( + ::Hashie::Mash.new(success: false) + ) + results = [] + subject.follow_pagination(:chunk, [], {}) { |item| results << item } + expect(results).to be_empty + end + + it 'does not send cursor param on first request' do + expect(subject).to receive(:chunk) do |params| + expect(params).not_to have_key(:cursor) + ::Hashie::Mash.new(data: [], success: true) + end + subject.follow_pagination(:chunk, [], {}) { } + end + end +end