patch_retention 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ # Work Session: 2025-05-26
2
+
3
+ ## Session Overview
4
+ Implemented Products and Memberships endpoints for Patch Retention API v2 gem, including comprehensive test coverage.
5
+
6
+ ## Completed Tasks
7
+
8
+ ### 1. Memberships Implementation
9
+ - ✅ Added `create` method for POST /v2/memberships
10
+ - ✅ Added `update` method for PATCH /v2/memberships/{id}
11
+ - ✅ Added `find` method for GET /v2/memberships/{id}
12
+ - ❌ Removed `cancel` and `expire` methods (not supported by API)
13
+
14
+ ### 2. Products Implementation
15
+ - ✅ Added `update` method for PATCH /v2/products/{id}
16
+ - ✅ Added `find` method for GET /v2/products/{id}
17
+ - Note: `create` method was already implemented
18
+
19
+ ### 3. Test Infrastructure
20
+ - ✅ Added VCR gem for recording/replaying HTTP interactions
21
+ - ✅ Added WebMock for HTTP stubbing
22
+ - ✅ Configured RSpec with VCR integration
23
+ - ✅ Added sensitive data filtering for credentials
24
+
25
+ ### 4. Comprehensive Test Coverage
26
+ - ✅ Created full test suite for Products (13 examples)
27
+ - ✅ Created full test suite for Memberships (13 examples)
28
+ - ✅ All 28 tests passing
29
+
30
+ ## Key Discoveries
31
+
32
+ ### API Behavior
33
+ 1. **ID Format**: API returns MongoDB-style IDs (e.g., `6833ef45...`) not prefixed IDs
34
+ 2. **Contact IDs**: Use `_id` field, not `id`
35
+ 3. **Tag Processing**: API capitalizes all tags automatically
36
+ 4. **External Data**: Accepted in requests but not always returned in responses
37
+ 5. **Default Values**: Memberships get `status: "PENDING"` by default
38
+
39
+ ### Test Data Used
40
+ - Client ID: `654173`
41
+ - Client Secret: `secret_A654173_KgaeVLoWzBLYQZ2uE21Du8yrXS7VXRU5e7yy9GYS3wnfPZbGNbYsmTkrmw8Z`
42
+ - Test endpoint: https://api.patchretention.com/v2
43
+
44
+ ## Next Steps
45
+ - [ ] Add Rubocop with Shopify style guide
46
+ - [ ] Add list/all methods for Products and Memberships
47
+ - [ ] Add delete operations if supported by API
48
+ - [ ] Implement pagination for list operations
49
+ - [ ] Add retry logic for transient failures
50
+
51
+ ## Files Modified
52
+ - `lib/patch_retention/memberships.rb` - Added update, find methods
53
+ - `lib/patch_retention/products.rb` - Added update, find methods
54
+ - `spec/patch_retention/memberships_spec.rb` - New comprehensive test suite
55
+ - `spec/patch_retention/products_spec.rb` - New comprehensive test suite
56
+ - `spec/spec_helper.rb` - Added VCR configuration
57
+ - `Gemfile` - Added VCR and WebMock gems
58
+ - `README.md` - Updated with new method documentation
59
+ - `CLAUDE.md` - Updated with findings and removed cancelled/expired references
60
+
61
+ ## Important Notes for Future Sessions
62
+ 1. Always use debug output in specs when tests fail
63
+ 2. Be aware of API field name differences (_id vs id)
64
+ 3. Expect tags to be capitalized
65
+ 4. Don't rely on external_data being returned
66
+ 5. Use flexible assertions for API responses
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module PatchRetention # rubocop:disable Style/ClassAndModuleChildren
3
+ module PatchRetention
4
4
  class Configuration
5
5
  attr_accessor :api_url, :proxy_url, :client_id, :client_secret
6
6
 
@@ -7,7 +7,10 @@ module PatchRetention::Contacts::FindOrCreate
7
7
  def call(contact_params:, query_params:, config: nil)
8
8
  raise_error_if_present do
9
9
  PatchRetention.connection(config).patch(PatchRetention::Contacts::API_PATH) do |req|
10
- req.params["search"] = build_search_string(query_params)
10
+ build_search_params(query_params).each do |key, value|
11
+ req.params[key] = value
12
+ end
13
+
11
14
  req.body = contact_params
12
15
  end
13
16
  end
@@ -15,11 +18,10 @@ module PatchRetention::Contacts::FindOrCreate
15
18
 
16
19
  private
17
20
 
18
- def build_search_string(query_params)
19
- query_params = query_params.slice(:email, :phone).compact.map do |key, value|
20
- { "search:#{key}" => value }
21
- end.reduce(&:merge)
22
-
23
- URI.encode_www_form(query_params)
21
+ # Returns a hash of search parameters, correctly prefixed
22
+ def build_search_params(query_params)
23
+ query_params.slice(:email, :phone).compact.transform_keys do |key|
24
+ "search:#{key}"
25
+ end
24
26
  end
25
27
  end
@@ -16,7 +16,7 @@ class PatchRetention::Contacts
16
16
  FindOrCreate.call(
17
17
  contact_params: contact_params,
18
18
  query_params: query_params,
19
- config: config
19
+ config: config,
20
20
  )
21
21
  end
22
22
 
@@ -12,7 +12,7 @@ module PatchRetention::Events::Create
12
12
  primary_key: primary_key_details[:key],
13
13
  primary_key_value: primary_key_details[:value],
14
14
  data: data,
15
- contact_upsert: contact_upsert
15
+ contact_upsert: contact_upsert,
16
16
  }.compact
17
17
 
18
18
  params.merge!(at: format_datetime(at)) unless at.nil?
@@ -19,7 +19,7 @@ class PatchRetention::Events
19
19
  data: data,
20
20
  at: at,
21
21
  contact_details: contact_details,
22
- config: config
22
+ config: config,
23
23
  )
24
24
  end
25
25
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchRetention
4
+ class Memberships
5
+ class << self
6
+ def create(contact_id:, product_id:, start_at: nil, end_at: nil, external_id: nil, external_data: nil,
7
+ tags: nil, config: nil)
8
+ payload = {
9
+ contact_id: contact_id,
10
+ product_id: product_id,
11
+ }
12
+ payload[:start_at] = start_at if start_at
13
+ payload[:end_at] = end_at if end_at
14
+ payload[:external_id] = external_id if external_id
15
+ payload[:external_data] = external_data if external_data
16
+ payload[:tags] = tags if tags
17
+
18
+ response = PatchRetention.connection(config).post("/v2/memberships") do |req|
19
+ req.body = payload.to_json
20
+ req.headers["Content-Type"] = "application/json"
21
+ end
22
+
23
+ JSON.parse(response.body)
24
+ rescue Faraday::Error => e
25
+ # You might want to handle different types of Faraday errors differently
26
+ # or raise a custom error class
27
+ raise Error, "Failed to create membership: #{e.message}"
28
+ end
29
+
30
+ def update(membership_id:, start_at: nil, end_at: nil, external_id: nil, external_data: nil,
31
+ tags: nil, status: nil, config: nil)
32
+ payload = {}
33
+ payload[:start_at] = start_at if start_at
34
+ payload[:end_at] = end_at if end_at
35
+ payload[:external_id] = external_id if external_id
36
+ payload[:external_data] = external_data if external_data
37
+ payload[:tags] = tags if tags
38
+ payload[:status] = status if status
39
+
40
+ response = PatchRetention.connection(config).patch("/v2/memberships/#{membership_id}") do |req|
41
+ req.body = payload.to_json
42
+ req.headers["Content-Type"] = "application/json"
43
+ end
44
+
45
+ JSON.parse(response.body)
46
+ rescue Faraday::Error => e
47
+ raise Error, "Failed to update membership: #{e.message}"
48
+ end
49
+
50
+ def find(membership_id:, config: nil)
51
+ response = PatchRetention.connection(config).get("/v2/memberships/#{membership_id}")
52
+
53
+ JSON.parse(response.body)
54
+ rescue Faraday::Error => e
55
+ raise Error, "Failed to find membership: #{e.message}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchRetention
4
+ class Products
5
+ class << self
6
+ def create(name:, price:, status:, description: nil, membership: nil,
7
+ tags: nil, external_id: nil, external_data: nil, config: nil)
8
+ payload = {
9
+ name: name,
10
+ price: price,
11
+ status: status,
12
+ }
13
+ payload[:description] = description if description
14
+ payload[:membership] = membership unless membership.nil?
15
+ payload[:tags] = tags if tags
16
+ payload[:external_id] = external_id if external_id
17
+ payload[:external_data] = external_data if external_data
18
+
19
+ response = PatchRetention.connection(config).post("/v2/products") do |req|
20
+ req.body = payload.to_json
21
+ req.headers["Content-Type"] = "application/json"
22
+ end
23
+
24
+ JSON.parse(response.body)
25
+ rescue Faraday::Error => e
26
+ # You might want to handle different types of Faraday errors differently
27
+ # or raise a custom error class
28
+ raise Error, "Failed to create product: #{e.message}"
29
+ end
30
+
31
+ def update(product_id:, name: nil, price: nil, status: nil, description: nil,
32
+ membership: nil, tags: nil, external_id: nil, external_data: nil, config: nil)
33
+ payload = {}
34
+ payload[:name] = name if name
35
+ payload[:price] = price if price
36
+ payload[:status] = status if status
37
+ payload[:description] = description if description
38
+ payload[:membership] = membership unless membership.nil?
39
+ payload[:tags] = tags if tags
40
+ payload[:external_id] = external_id if external_id
41
+ payload[:external_data] = external_data if external_data
42
+
43
+ response = PatchRetention.connection(config).patch("/v2/products/#{product_id}") do |req|
44
+ req.body = payload.to_json
45
+ req.headers["Content-Type"] = "application/json"
46
+ end
47
+
48
+ JSON.parse(response.body)
49
+ rescue Faraday::Error => e
50
+ raise Error, "Failed to update product: #{e.message}"
51
+ end
52
+
53
+ def find(product_id:, config: nil)
54
+ response = PatchRetention.connection(config).get("/v2/products/#{product_id}")
55
+
56
+ JSON.parse(response.body)
57
+ rescue Faraday::Error => e
58
+ raise Error, "Failed to find product: #{e.message}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module PatchRetention # rubocop:disable Style/ClassAndModuleChildren
3
+ module PatchRetention
4
4
  # The Util module provides utility methods used across the PatchRetention library.
5
5
  # These methods are designed to handle common tasks such as error handling and response parsing.
6
6
  module Util
@@ -25,7 +25,7 @@ module PatchRetention # rubocop:disable Style/ClassAndModuleChildren
25
25
  def parse_error_message(response)
26
26
  if response.status == 502 && response.body.blank?
27
27
  raise PatchRetention::Error,
28
- "Internal Server Error: Patch API"
28
+ "Internal Server Error: Patch API"
29
29
  end
30
30
 
31
31
  JSON.parse(response.body)["error"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatchRetention
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -26,17 +26,17 @@ module PatchRetention
26
26
  end
27
27
 
28
28
  def connection(config = nil)
29
- config = config || configuration
29
+ config ||= configuration
30
30
 
31
31
  Faraday.new(
32
32
  url: config.api_url,
33
33
  proxy: config.proxy_url,
34
34
  headers: {
35
35
  "Authorization" => "Bearer #{config.client_secret}",
36
- "X-Account-Id" => config.client_id
37
- }
36
+ "X-Account-Id" => config.client_id,
37
+ },
38
38
  ) do |builder|
39
- builder.request :json
39
+ builder.request(:json)
40
40
  end
41
41
  end
42
42
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/patch_retention/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "patch_retention"
7
+ spec.version = PatchRetention::VERSION
8
+ spec.authors = ["Playbypoint", "Gerardo Ortega"]
9
+ spec.email = ["webmaster@playbypoint.com", "g3ortega@gmail.com"]
10
+
11
+ spec.summary = "Patch Retention API wrapper."
12
+ spec.description = "Patch Retention API wrapper."
13
+ spec.homepage = "https://playbypoint.com"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
+
17
+ # spec.metadata["allowed_push_host"] = "Set to your gem server 'https://example.com'"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://playbypoint.com"
21
+ spec.metadata["changelog_uri"] = "https://playbypoint.com"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ %x(git ls-files -z).split("\x0").reject do |f|
27
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Runtime dependencies
35
+ spec.add_dependency("faraday", "~> 2.0")
36
+ spec.add_dependency("zeitwerk", "~> 2.6")
37
+
38
+ # Development dependencies
39
+ spec.add_development_dependency("bundler", "~> 2.0")
40
+ spec.add_development_dependency("bundler-audit", "~> 0.9")
41
+ spec.add_development_dependency("byebug")
42
+ spec.add_development_dependency("dotenv", "~> 2.8")
43
+ spec.add_development_dependency("pry")
44
+ spec.add_development_dependency("rake", "~> 13.0")
45
+ spec.add_development_dependency("rspec", "~> 3.0")
46
+ spec.add_development_dependency("rubocop", "~> 1.21")
47
+ spec.add_development_dependency("rubocop-shopify", "~> 2.15")
48
+ spec.add_development_dependency("vcr", "~> 6.0")
49
+ spec.add_development_dependency("webmock", "~> 3.0")
50
+
51
+ # Security update for rexml
52
+ spec.add_development_dependency("rexml", ">= 3.3.9")
53
+
54
+ # For more information and examples about making a new gem, check out our
55
+ # guide at: https://bundler.io/guides/creating_gem.html
56
+ end
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+
6
+ puts "Setting up test environment..."
7
+
8
+ env_file = ".env.test"
9
+
10
+ # Check if .env.test already exists
11
+ if File.exist?(env_file)
12
+ print "#{env_file} already exists. Overwrite? (y/n): "
13
+ response = gets.chomp.downcase
14
+ exit unless response == "y"
15
+ end
16
+
17
+ # Check for environment variables
18
+ client_id = ENV["PATCH_RETENTION_TEST_CLIENT_ID"] || ENV["PATCH_RETENTION_CLIENT_ID"]
19
+ client_secret = ENV["PATCH_RETENTION_TEST_CLIENT_SECRET"] || ENV["PATCH_RETENTION_CLIENT_SECRET"]
20
+
21
+ if client_id.nil? || client_secret.nil?
22
+ puts "\nError: Test credentials not found in environment variables."
23
+ puts "\nPlease set the following environment variables:"
24
+ puts " PATCH_RETENTION_TEST_CLIENT_ID"
25
+ puts " PATCH_RETENTION_TEST_CLIENT_SECRET"
26
+ puts "\nYou can export them in your shell:"
27
+ puts " export PATCH_RETENTION_TEST_CLIENT_ID=your_client_id"
28
+ puts " export PATCH_RETENTION_TEST_CLIENT_SECRET=your_client_secret"
29
+ puts "\nOr create a .env.test file manually with:"
30
+ puts " PATCH_RETENTION_CLIENT_ID=your_client_id"
31
+ puts " PATCH_RETENTION_CLIENT_SECRET=your_client_secret"
32
+ puts " PATCH_RETENTION_API_URL=https://api.patchretention.com/v2"
33
+ exit 1
34
+ end
35
+
36
+ # Create .env.test file
37
+ File.open(env_file, "w") do |f|
38
+ f.puts "PATCH_RETENTION_CLIENT_ID=#{client_id}"
39
+ f.puts "PATCH_RETENTION_CLIENT_SECRET=#{client_secret}"
40
+ f.puts "PATCH_RETENTION_API_URL=https://api.patchretention.com/v2"
41
+ end
42
+
43
+ puts "✓ Created #{env_file} successfully!"
44
+ puts "\nYou can now run tests with: bundle exec rake spec"