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.
- checksums.yaml +4 -4
- data/.env.development +5 -0
- data/.env.example +5 -0
- data/.env.test.example +5 -0
- data/.rubocop.yml +33 -21
- data/.ruby-version +1 -0
- data/.tool-versions +1 -1
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +248 -0
- data/DEPENDENCY_UPDATE_REPORT.md +89 -0
- data/Gemfile +0 -12
- data/Gemfile.lock +44 -12
- data/README.md +189 -3
- data/Rakefile +1 -1
- data/docs/decisions/001-api-implementation.md +66 -0
- data/docs/patterns/testing-patterns.md +152 -0
- data/docs/references/github-actions.md +100 -0
- data/docs/references/troubleshooting.md +122 -0
- data/docs/wip/session-2025-05-26.md +66 -0
- data/lib/patch_retention/configuration.rb +1 -1
- data/lib/patch_retention/contacts/find_or_create.rb +9 -7
- data/lib/patch_retention/contacts.rb +1 -1
- data/lib/patch_retention/events/create.rb +1 -1
- data/lib/patch_retention/events.rb +1 -1
- data/lib/patch_retention/memberships.rb +59 -0
- data/lib/patch_retention/products.rb +62 -0
- data/lib/patch_retention/util.rb +2 -2
- data/lib/patch_retention/version.rb +1 -1
- data/lib/patch_retention.rb +4 -4
- data/patch_retention.gemspec +56 -0
- data/script/setup_test_env +44 -0
- metadata +219 -8
@@ -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
|
@@ -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
|
-
|
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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
@@ -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?
|
@@ -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
|
data/lib/patch_retention/util.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module PatchRetention
|
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
|
-
|
28
|
+
"Internal Server Error: Patch API"
|
29
29
|
end
|
30
30
|
|
31
31
|
JSON.parse(response.body)["error"]
|
data/lib/patch_retention.rb
CHANGED
@@ -26,17 +26,17 @@ module PatchRetention
|
|
26
26
|
end
|
27
27
|
|
28
28
|
def connection(config = nil)
|
29
|
-
config
|
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
|
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"
|