From f8651f84e18cc2cf8b89b7fcb139db7f8ecf41ae Mon Sep 17 00:00:00 2001 From: BJCLark Date: Fri, 26 Jun 2026 22:05:09 -0700 Subject: [PATCH] Regenerate Pact contract from consumer specs (full coverage + error paths) - Fix VCR load failure on Ruby 4 (CGI.parse removed in 4.0) by bumping vcr 6.2 -> 6.4, which unblocks the pact consumer + adapter specs. - Expand pact consumer specs to send all declared request fields, assert all declared response fields with type matchers, add 4xx error-path interactions, and cover PATCH /api/accounts/:key plus the feature resolve routes. - Add PATCH delegation to the HTTP adapter (required for the new route). - Regenerate the committed pact from the passing specs (7 -> 21 interactions). Brings contract operation coverage to 100% and error-path coverage to 100%, depth 72.1% -> 92.6% per `mise run spec:coverage`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ruby.yml | 26 +- .gitignore | 5 + Gemfile.lock | 89 ++- Rakefile | 17 + kickplan.gemspec | 2 + lib/kickplan/adapters/http.rb | 2 +- spec/pact/accounts_pact_spec.rb | 256 +++++++ spec/pact/billable_objects_pact_spec.rb | 81 +++ spec/pact/features_pact_spec.rb | 323 +++++++++ spec/pact/metrics_pact_spec.rb | 122 ++++ spec/pact_helper.rb | 33 + spec/pacts/kickplansdk-kickplanapi.json | 865 ++++++++++++++++++++++++ spec/spec_helper.rb | 4 + 13 files changed, 1822 insertions(+), 3 deletions(-) create mode 100644 spec/pact/accounts_pact_spec.rb create mode 100644 spec/pact/billable_objects_pact_spec.rb create mode 100644 spec/pact/features_pact_spec.rb create mode 100644 spec/pact/metrics_pact_spec.rb create mode 100644 spec/pact_helper.rb create mode 100644 spec/pacts/kickplansdk-kickplanapi.json diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 395d0d5..79c170e 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -29,7 +29,31 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + bundler-cache: true - name: Run tests run: bundle exec rake + + pact: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Run pact tests + run: bundle exec rspec spec/pact/ + + - name: Publish pacts + if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' + env: + PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }} + PACT_BROKER_USERNAME: ${{ secrets.PACT_BROKER_USERNAME }} + PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }} + run: bundle exec rake pact:publish diff --git a/.gitignore b/.gitignore index 5e81133..a96b5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ # rspec failure tracking .rspec_status + +# pact logs +spec/pact_logs/ + +/log/ diff --git a/Gemfile.lock b/Gemfile.lock index 25a54b2..25c37bc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,13 +14,16 @@ GEM specs: addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) + awesome_print (1.9.2) base64 (0.3.0) bigdecimal (3.1.8) coderay (1.1.3) concurrent-ruby (1.0.3) crack (0.4.5) rexml + csv (3.3.5) diff-lcs (1.5.0) + dig_rb (1.0.1) dotenv (2.8.1) dry-configurable (1.0.0) dry-core (~> 1.0, < 2) @@ -44,18 +47,87 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) + expgen (0.1.1) + parslet faraday (2.7.10) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) + find_a_port (1.0.1) hashdiff (1.0.1) + httparty (0.24.2) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) ice_nine (0.11.2) + io-console (0.8.2) + json (2.18.1) + jsonpath (1.1.5) + multi_json + logger (1.7.0) method_source (1.0.0) + mini_mime (1.1.5) + mize (0.6.1) + multi_json (1.19.1) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) + ostruct (0.6.3) + pact (1.67.4) + pact-mock_service (~> 3.0, >= 3.3.1) + pact-support (~> 1.21, >= 1.21.2) + rack + rack-proxy + rack-test (>= 0.6.3, < 3.0.0) + rainbow (~> 3.1) + rspec (~> 3.0) + thor (>= 0.20, < 2.0) + webrick (~> 1.8) + zeitwerk (~> 2.3) + pact-mock_service (3.12.4) + find_a_port (~> 1.0.1) + json + logger (< 2.0) + pact-support (~> 1.16, >= 1.16.4) + rack (>= 3.0, < 4.0) + rackup (~> 2.0) + rspec (>= 2.14) + thor (>= 0.19, < 2.0) + webrick (~> 1.8) + pact-support (1.21.2) + awesome_print (~> 1.9) + diff-lcs (~> 1.5) + expgen (~> 0.1) + jsonpath (~> 1.0) + rainbow (~> 3.1.1) + string_pattern (~> 2.0) + pact_broker-client (1.77.0) + base64 (~> 0.2) + dig_rb (~> 1.0) + httparty (>= 0.21.0, < 1.0.0) + ostruct + rake (~> 13.0) + table_print (~> 1.5) + term-ansicolor (~> 1.7) + thor (>= 0.20, < 2.0) + parslet (2.0.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) public_suffix (5.0.3) + rack (3.2.4) + rack-proxy (0.7.7) + rack + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rainbow (3.1.1) rake (13.0.1) + readline (0.0.4) + reline + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) rexml (3.2.6) rspec (3.12.0) rspec-core (~> 3.12.0) @@ -71,11 +143,24 @@ GEM rspec-support (~> 3.12.0) rspec-support (3.12.1) ruby2_keywords (0.0.5) - vcr (6.2.0) + string_pattern (2.3.0) + regexp_parser (~> 2.5, >= 2.5.0) + sync (0.5.0) + table_print (1.5.7) + term-ansicolor (1.11.3) + tins (~> 1) + thor (1.5.0) + tins (1.51.1) + bigdecimal + mize (~> 0.6) + readline + sync + vcr (6.4.0) webmock (3.19.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.2) zeitwerk (2.6.11) PLATFORMS @@ -86,6 +171,8 @@ DEPENDENCIES bundler (~> 2.0) dotenv (~> 2.0) kickplan-sdk! + pact (~> 1.64) + pact_broker-client (~> 1.75) pry (~> 0.14) rake (~> 13.0) rspec (~> 3.0) diff --git a/Rakefile b/Rakefile index b6ae734..d9d2d4f 100644 --- a/Rakefile +++ b/Rakefile @@ -6,3 +6,20 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) task default: :spec + +begin + require "pact_broker/client/tasks" + + PactBroker::Client::PublicationTask.new do |task| + task.consumer_version = `git rev-parse --short HEAD`.strip + task.pact_broker_base_url = ENV.fetch("PACT_BROKER_BASE_URL", "http://localhost:9292") + task.pact_broker_basic_auth = { + username: ENV.fetch("PACT_BROKER_USERNAME", "pact"), + password: ENV.fetch("PACT_BROKER_PASSWORD", "pact") + } + task.branch = `git rev-parse --abbrev-ref HEAD`.strip + task.tag_with_git_branch = true + end +rescue LoadError + # pact_broker-client not available outside dev +end diff --git a/kickplan.gemspec b/kickplan.gemspec index 8c993d1..7fcf8fe 100644 --- a/kickplan.gemspec +++ b/kickplan.gemspec @@ -35,4 +35,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "webmock", "~> 3.5" spec.add_development_dependency "vcr", "~> 6.0" + spec.add_development_dependency "pact", "~> 1.64" + spec.add_development_dependency "pact_broker-client", "~> 1.75" end diff --git a/lib/kickplan/adapters/http.rb b/lib/kickplan/adapters/http.rb index f55d158..00b073f 100644 --- a/lib/kickplan/adapters/http.rb +++ b/lib/kickplan/adapters/http.rb @@ -5,7 +5,7 @@ module Adapters class HTTP < Adapter extend Forwardable - delegate %i(get post put delete) => :connection + delegate %i(get post put patch delete) => :connection # @deprecated def configure_account(params) diff --git a/spec/pact/accounts_pact_spec.rb b/spec/pact/accounts_pact_spec.rb new file mode 100644 index 0000000..5a18460 --- /dev/null +++ b/spec/pact/accounts_pact_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +require "pact_helper" + +RSpec.describe "Accounts Pact", pact: true do + let(:accounts) { pact_client::Accounts } + + describe "create account" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to create an account") + .with( + method: :post, + path: "/api/accounts", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + key: "acme", + name: "Acme Inc.", + custom_fields: { "tier" => "gold" }, + account_plans: [{ plan_key: "small" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "variant_key", variant_key: "high" } + ] + } + ) + .will_respond_with( + status: 201, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("acme"), + name: Pact.like("Acme Inc."), + custom_fields: Pact.like({ "tier" => "gold" }), + account_plans: Pact.each_like("small"), + feature_overrides: Pact.each_like( + feature_key: "contact-limit", + variant_key: "high" + ) + } + ) + end + + it "returns an Account" do + response = accounts.create( + key: "acme", + name: "Acme Inc.", + custom_fields: { "tier" => "gold" }, + account_plans: [{ plan_key: "small" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "variant_key", variant_key: "high" } + ] + ) + + expect(response).to be_a Kickplan::Schemas::Account + expect(response.key).to eq "acme" + expect(response.custom_fields).to eq("tier" => "gold") + expect(response.account_plans).to eq(["small"]) + end + end + + describe "create account (validation error)" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to create an account with a missing key") + .with( + method: :post, + path: "/api/accounts", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + name: "Acme Inc." + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.post("accounts", { name: "Acme Inc." }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + describe "update account (PUT)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to update an account via PUT") + .with( + method: :put, + path: "/api/accounts/acme", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + name: "Acme Corp.", + custom_fields: { "tier" => "platinum" }, + account_plans: [{ plan_key: "large" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "default_on" } + ] + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("acme"), + name: Pact.like("Acme Corp."), + custom_fields: Pact.like({ "tier" => "platinum" }), + account_plans: Pact.each_like("large"), + feature_overrides: Pact.each_like( + feature_key: "contact-limit", + override: "default_on" + ) + } + ) + end + + it "returns an updated Account" do + response = accounts.update("acme", + name: "Acme Corp.", + custom_fields: { "tier" => "platinum" }, + account_plans: [{ plan_key: "large" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "default_on" } + ] + ) + + expect(response).to be_a Kickplan::Schemas::Account + expect(response.key).to eq "acme" + end + end + + describe "update account (PATCH)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to update an account via PATCH") + .with( + method: :patch, + path: "/api/accounts/acme", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + name: "Acme Corp.", + custom_fields: { "tier" => "platinum" }, + account_plans: [{ plan_key: "large" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "default_off" } + ] + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("acme"), + name: Pact.like("Acme Corp."), + custom_fields: Pact.like({ "tier" => "platinum" }), + account_plans: Pact.each_like("large"), + feature_overrides: Pact.each_like( + feature_key: "contact-limit", + override: "default_off" + ) + } + ) + end + + it "returns an updated Account" do + params = Kickplan::Requests::Accounts::Update.new( + name: "Acme Corp.", + custom_fields: { "tier" => "platinum" }, + account_plans: [{ plan_key: "large" }], + feature_overrides: [ + { feature_key: "contact-limit", override: "default_off" } + ] + ) + + response = pact_client.adapter.patch("accounts/acme", params.to_h) + + expect(response.status).to eq 200 + expect(response.body["key"]).to eq "acme" + end + end + + describe "update account (not found)" do + before do + kickplan_api + .given("no account with key 'ghost' exists") + .upon_receiving("a request to update a missing account") + .with( + method: :put, + path: "/api/accounts/ghost", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + name: "Ghost Corp." + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.put("accounts/ghost", { name: "Ghost Corp." }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + describe "update account (PATCH, not found)" do + before do + kickplan_api + .given("no account with key 'ghost' exists") + .upon_receiving("a PATCH request to update a missing account") + .with( + method: :patch, + path: "/api/accounts/ghost", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + name: "Ghost Corp." + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.patch("accounts/ghost", { name: "Ghost Corp." }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end +end diff --git a/spec/pact/billable_objects_pact_spec.rb b/spec/pact/billable_objects_pact_spec.rb new file mode 100644 index 0000000..fa7d5ab --- /dev/null +++ b/spec/pact/billable_objects_pact_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "pact_helper" + +RSpec.describe "BillableObjects Pact", pact: true do + let(:billable_objects) { pact_client::BillableObjects } + + describe "upsert billable object" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to upsert a billable object") + .with( + method: :post, + path: "/api/billable_objects", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + external_id: "1234", + external_type: "license", + account_key: "acme", + properties: { active: true } + } + ) + .will_respond_with( + status: 201, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + external_id: Pact.like("1234"), + external_type: Pact.like("license"), + account_key: Pact.like("acme"), + properties: Pact.like({ "active" => true }) + } + ) + end + + it "returns a BillableObject" do + response = billable_objects.upsert( + external_id: "1234", + external_type: "license", + account_key: "acme", + properties: { active: true } + ) + + expect(response).to be_a Kickplan::Schemas::BillableObject + expect(response.external_id).to eq "1234" + end + end + + describe "upsert billable object (validation error)" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to upsert a billable object with a missing account_key") + .with( + method: :post, + path: "/api/billable_objects", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + external_id: "1234", + external_type: "license" + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.post("billable_objects", { external_id: "1234", external_type: "license" }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end +end diff --git a/spec/pact/features_pact_spec.rb b/spec/pact/features_pact_spec.rb new file mode 100644 index 0000000..1f14ae6 --- /dev/null +++ b/spec/pact/features_pact_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require "pact_helper" + +RSpec.describe "Features Pact", pact: true do + let(:features) { pact_client::Features } + + describe "resolve single feature" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve a single feature") + .with( + method: :post, + path: "/api/features/contact-limit/resolve", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("contact-limit"), + value: Pact.like(10) + } + ) + end + + it "returns a Resolution" do + response = features.resolve("contact-limit", context: { account_key: "acme" }) + + expect(response).to be_a Kickplan::Schemas::Resolution + expect(response.key).to eq "contact-limit" + expect(response.value).not_to be_nil + end + end + + describe "resolve single feature (detailed)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve a single feature in detail") + .with( + method: :post, + path: "/api/features/contact-limit/resolve", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: true, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("contact-limit"), + value: Pact.like(10), + variant: Pact.like("high"), + reason: Pact.like("TARGETING_MATCH"), + metadata: Pact.like({ "name" => "Contact Limit" }), + error_code: Pact.like(nil), + error_message: Pact.like(nil) + } + ) + end + + it "returns a detailed Resolution" do + response = features.resolve("contact-limit", detailed: true, context: { account_key: "acme" }) + + expect(response).to be_a Kickplan::Schemas::Resolution + expect(response.variant).to eq "high" + expect(response.metadata).to eq("name" => "Contact Limit") + end + end + + describe "resolve single feature (not found)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve a missing feature") + .with( + method: :post, + path: "/api/features/ghost-flag/resolve", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + params = Kickplan::Requests::Features::Resolve.new(context: { account_key: "acme" }) + expect do + pact_client.adapter.post("features/ghost-flag/resolve", params.to_h) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + describe "resolve all features" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve all features") + .with( + method: :post, + path: "/api/features/resolve", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: Pact.each_like( + key: "contact-limit", + value: 10 + ) + ) + end + + it "returns an array of Resolutions" do + response = features.resolve(context: { account_key: "acme" }) + + expect(response).to be_a Array + expect(response).to all be_a Kickplan::Schemas::Resolution + end + end + + describe "resolve all features (no context, error)" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to resolve all features without a context") + .with( + method: :post, + path: "/api/features/resolve", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.post("features/resolve", { detailed: false }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + # POST /api/features is an alternate "resolve all" route (FeatureController#resolve). + describe "resolve all features (POST /api/features)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve all features via POST /api/features") + .with( + method: :post, + path: "/api/features", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: true, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: Pact.each_like( + key: "contact-limit", + value: 10, + variant: "high", + reason: "TARGETING_MATCH", + metadata: { "name" => "Contact Limit" }, + error_code: nil, + error_message: nil + ) + ) + end + + it "returns resolutions" do + params = Kickplan::Requests::Features::Resolve.new(detailed: true, context: { account_key: "acme" }) + response = pact_client.adapter.post("features", params.to_h) + + expect(response.status).to eq 200 + expect(response.body.first["key"]).to eq "contact-limit" + end + end + + describe "resolve all features (POST /api/features, error)" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to POST /api/features without a context") + .with( + method: :post, + path: "/api/features", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.post("features", { detailed: false }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + # POST /api/features/:key is an alternate "resolve single" route (FeatureController#resolve). + describe "resolve single feature (POST /api/features/:key)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to resolve a single feature via POST /api/features/:key") + .with( + method: :post, + path: "/api/features/contact-limit", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: true, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 200, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) }, + body: { + key: Pact.like("contact-limit"), + value: Pact.like(10), + variant: Pact.like("high"), + reason: Pact.like("TARGETING_MATCH"), + metadata: Pact.like({ "name" => "Contact Limit" }), + error_code: Pact.like(nil), + error_message: Pact.like(nil) + } + ) + end + + it "returns a Resolution" do + params = Kickplan::Requests::Features::Resolve.new(detailed: true, context: { account_key: "acme" }) + response = pact_client.adapter.post("features/contact-limit", params.to_h) + + expect(response.status).to eq 200 + expect(response.body["key"]).to eq "contact-limit" + end + end + + describe "resolve single feature (POST /api/features/:key, not found)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to POST /api/features/:key for a missing feature") + .with( + method: :post, + path: "/api/features/ghost-flag", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + detailed: false, + context: { account_key: "acme" } + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + params = Kickplan::Requests::Features::Resolve.new(context: { account_key: "acme" }) + expect do + pact_client.adapter.post("features/ghost-flag", params.to_h) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end +end diff --git a/spec/pact/metrics_pact_spec.rb b/spec/pact/metrics_pact_spec.rb new file mode 100644 index 0000000..947b246 --- /dev/null +++ b/spec/pact/metrics_pact_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "pact_helper" +require "date" + +RSpec.describe "Metrics Pact", pact: true do + let(:metrics) { pact_client::Metrics } + + describe "set metric" do + let(:time) { DateTime.parse("2026-06-14T12:00:00+00:00") } + + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to set a metric") + .with( + method: :post, + path: "/api/metrics/set", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + key: "seats_used", + value: 3, + account_key: "acme", + idempotency_key: "idem-2026-06-14", + time: "2026-06-14T12:00:00+00:00" + } + ) + .will_respond_with(status: 202) + end + + it "returns true" do + result = metrics.set( + key: "seats_used", + value: 3, + account_key: "acme", + idempotency_key: "idem-2026-06-14", + time: time + ) + + expect(result).to be true + end + end + + describe "set metric (validation error)" do + before do + kickplan_api + .given("an account with key 'acme' exists") + .upon_receiving("a request to set a metric with a missing value") + .with( + method: :post, + path: "/api/metrics/set", + headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer pact-test-token" + }, + body: { + key: "seats_used", + account_key: "acme" + } + ) + .will_respond_with( + status: 400, + headers: { "Content-Type" => Pact.term("application/json", /application\/json/) } + ) + end + + it "raises a BadRequest error" do + expect do + pact_client.adapter.post("metrics/set", { key: "seats_used", account_key: "acme" }) + end.to raise_error(Kickplan::Errors::BadRequest) + end + end + + describe "flush metrics" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("a request to flush metrics") + .with( + method: :post, + path: "/api/metrics/flush", + headers: { + "Authorization" => "Bearer pact-test-token" + } + ) + .will_respond_with(status: 202) + end + + it "returns true" do + result = metrics.flush + + expect(result).to be true + end + end + + describe "flush metrics (unauthorized)" do + before do + kickplan_api + .given("a valid environment") + .upon_receiving("an unauthorized request to flush metrics") + .with( + method: :post, + path: "/api/metrics/flush", + headers: { + "Authorization" => "Bearer wrong-token" + } + ) + .will_respond_with(status: 401) + end + + it "raises a ServiceError" do + expect do + pact_client.adapter.connection.post("metrics/flush") do |req| + req.headers["Authorization"] = "Bearer wrong-token" + end + end.to raise_error(Kickplan::ServiceError) + end + end +end diff --git a/spec/pact_helper.rb b/spec/pact_helper.rb new file mode 100644 index 0000000..e1ed2c8 --- /dev/null +++ b/spec/pact_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "pact/consumer/rspec" +require "kickplan" + +Pact.service_consumer "KickplanSDK" do + has_pact_with "KickplanAPI" do + mock_service :kickplan_api do + port 1234 + end + end +end + +Kickplan[:pact].configure do |config| + config.adapter = :http + config.endpoint = "http://localhost:1234/api" + config.access_token = "pact-test-token" +end + +Pact.configure do |config| + config.pact_dir = File.expand_path("pacts", __dir__) + config.log_dir = File.expand_path("../spec/pact_logs", __dir__) +end + +module PactHelpers + def pact_client + Kickplan.client(:pact) + end +end + +RSpec.configure do |config| + config.include PactHelpers +end diff --git a/spec/pacts/kickplansdk-kickplanapi.json b/spec/pacts/kickplansdk-kickplanapi.json new file mode 100644 index 0000000..dea5e8d --- /dev/null +++ b/spec/pacts/kickplansdk-kickplanapi.json @@ -0,0 +1,865 @@ +{ + "consumer": { + "name": "KickplanSDK" + }, + "provider": { + "name": "KickplanAPI" + }, + "interactions": [ + { + "description": "a request to create an account", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/accounts", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "key": "acme", + "name": "Acme Inc.", + "custom_fields": { + "tier": "gold" + }, + "account_plans": [ + { + "plan_key": "small" + } + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "override": "variant_key", + "variant_key": "high" + } + ] + } + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "acme", + "name": "Acme Inc.", + "custom_fields": { + "tier": "gold" + }, + "account_plans": [ + "small" + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "variant_key": "high" + } + ] + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.name": { + "match": "type" + }, + "$.body.custom_fields": { + "match": "type" + }, + "$.body.account_plans": { + "min": 1 + }, + "$.body.account_plans[*].*": { + "match": "type" + }, + "$.body.feature_overrides": { + "min": 1 + }, + "$.body.feature_overrides[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a PATCH request to update a missing account", + "providerState": "no account with key 'ghost' exists", + "request": { + "method": "patch", + "path": "/api/accounts/ghost", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "name": "Ghost Corp." + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to update an account via PUT", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "put", + "path": "/api/accounts/acme", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "name": "Acme Corp.", + "custom_fields": { + "tier": "platinum" + }, + "account_plans": [ + { + "plan_key": "large" + } + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "override": "default_on" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "acme", + "name": "Acme Corp.", + "custom_fields": { + "tier": "platinum" + }, + "account_plans": [ + "large" + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "override": "default_on" + } + ] + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.name": { + "match": "type" + }, + "$.body.custom_fields": { + "match": "type" + }, + "$.body.account_plans": { + "min": 1 + }, + "$.body.account_plans[*].*": { + "match": "type" + }, + "$.body.feature_overrides": { + "min": 1 + }, + "$.body.feature_overrides[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a request to create an account with a missing key", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/accounts", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "name": "Acme Inc." + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to update a missing account", + "providerState": "no account with key 'ghost' exists", + "request": { + "method": "put", + "path": "/api/accounts/ghost", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "name": "Ghost Corp." + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to update an account via PATCH", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "patch", + "path": "/api/accounts/acme", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "name": "Acme Corp.", + "custom_fields": { + "tier": "platinum" + }, + "account_plans": [ + { + "plan_key": "large" + } + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "override": "default_off" + } + ] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "acme", + "name": "Acme Corp.", + "custom_fields": { + "tier": "platinum" + }, + "account_plans": [ + "large" + ], + "feature_overrides": [ + { + "feature_key": "contact-limit", + "override": "default_off" + } + ] + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.name": { + "match": "type" + }, + "$.body.custom_fields": { + "match": "type" + }, + "$.body.account_plans": { + "min": 1 + }, + "$.body.account_plans[*].*": { + "match": "type" + }, + "$.body.feature_overrides": { + "min": 1 + }, + "$.body.feature_overrides[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a request to resolve a missing feature", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/ghost-flag/resolve", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to resolve all features", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/resolve", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": [ + { + "key": "contact-limit", + "value": 10 + } + ], + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body": { + "min": 1 + }, + "$.body[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a request to resolve all features without a context", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/features/resolve", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to resolve a single feature via POST /api/features/:key", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/contact-limit", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": true, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "contact-limit", + "value": 10, + "variant": "high", + "reason": "TARGETING_MATCH", + "metadata": { + "name": "Contact Limit" + }, + "error_code": null, + "error_message": null + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.value": { + "match": "type" + }, + "$.body.variant": { + "match": "type" + }, + "$.body.reason": { + "match": "type" + }, + "$.body.metadata": { + "match": "type" + }, + "$.body.error_code": { + "match": "type" + }, + "$.body.error_message": { + "match": "type" + } + } + } + }, + { + "description": "a request to resolve a single feature", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/contact-limit/resolve", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "contact-limit", + "value": 10 + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.value": { + "match": "type" + } + } + } + }, + { + "description": "a request to resolve a single feature in detail", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/contact-limit/resolve", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": true, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "key": "contact-limit", + "value": 10, + "variant": "high", + "reason": "TARGETING_MATCH", + "metadata": { + "name": "Contact Limit" + }, + "error_code": null, + "error_message": null + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.key": { + "match": "type" + }, + "$.body.value": { + "match": "type" + }, + "$.body.variant": { + "match": "type" + }, + "$.body.reason": { + "match": "type" + }, + "$.body.metadata": { + "match": "type" + }, + "$.body.error_code": { + "match": "type" + }, + "$.body.error_message": { + "match": "type" + } + } + } + }, + { + "description": "a request to POST /api/features/:key for a missing feature", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features/ghost-flag", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to resolve all features via POST /api/features", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/features", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": true, + "context": { + "account_key": "acme" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": [ + { + "key": "contact-limit", + "value": 10, + "variant": "high", + "reason": "TARGETING_MATCH", + "metadata": { + "name": "Contact Limit" + }, + "error_code": null, + "error_message": null + } + ], + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body": { + "min": 1 + }, + "$.body[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a request to POST /api/features without a context", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/features", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "detailed": false + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to upsert a billable object", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/billable_objects", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "external_id": "1234", + "external_type": "license", + "account_key": "acme", + "properties": { + "active": true + } + } + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "external_id": "1234", + "external_type": "license", + "account_key": "acme", + "properties": { + "active": true + } + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + }, + "$.body.external_id": { + "match": "type" + }, + "$.body.external_type": { + "match": "type" + }, + "$.body.account_key": { + "match": "type" + }, + "$.body.properties": { + "match": "type" + } + } + } + }, + { + "description": "a request to upsert a billable object with a missing account_key", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/billable_objects", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "external_id": "1234", + "external_type": "license" + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to set a metric", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/metrics/set", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "key": "seats_used", + "value": 3, + "account_key": "acme", + "idempotency_key": "idem-2026-06-14", + "time": "2026-06-14T12:00:00+00:00" + } + }, + "response": { + "status": 202, + "headers": { + } + } + }, + { + "description": "an unauthorized request to flush metrics", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/metrics/flush", + "headers": { + "Authorization": "Bearer wrong-token" + } + }, + "response": { + "status": 401, + "headers": { + } + } + }, + { + "description": "a request to set a metric with a missing value", + "providerState": "an account with key 'acme' exists", + "request": { + "method": "post", + "path": "/api/metrics/set", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer pact-test-token" + }, + "body": { + "key": "seats_used", + "account_key": "acme" + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "$.headers.Content-Type": { + "match": "regex", + "regex": "application\\/json" + } + } + } + }, + { + "description": "a request to flush metrics", + "providerState": "a valid environment", + "request": { + "method": "post", + "path": "/api/metrics/flush", + "headers": { + "Authorization": "Bearer pact-test-token" + } + }, + "response": { + "status": 202, + "headers": { + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f513cc6..ec746af 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,6 +28,10 @@ c.hook_into :faraday, :webmock c.configure_rspec_metadata! + c.ignore_request do |request| + URI(request.uri).port == 1234 + end + c.filter_sensitive_data("") { ENV["KICKPLAN_ACCESS_TOKEN"] } c.before_http_request do |request|