chore: shopify tool calling
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
module Shopify::IntegrationHelper
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments read_products].freeze
|
||||
|
||||
# Generates a signed JWT token for Shopify integration
|
||||
#
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
class Integrations::Shopify::ClientService
|
||||
include Shopify::IntegrationHelper
|
||||
|
||||
API_VERSION = '2025-01'.freeze
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def fetch_client
|
||||
Rails.logger.info("[Integrations::Shopify::ClientService] fetch_client account_id=#{@account.id}")
|
||||
unless hook
|
||||
Rails.logger.warn("[Integrations::Shopify::ClientService] not_connected account_id=#{@account.id}")
|
||||
return failure(:not_connected, 'Shopify integration is not connected.')
|
||||
end
|
||||
|
||||
setup_shopify_context
|
||||
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ClientService] connected account_id=#{@account.id} " \
|
||||
"shop=#{hook.reference_id} scopes=#{granted_scopes.join(',')}"
|
||||
)
|
||||
|
||||
success(
|
||||
client: ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session),
|
||||
hook: hook,
|
||||
scopes: granted_scopes
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ClientService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Unable to communicate with Shopify.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def scopes_include?(*required_scopes)
|
||||
required_scopes.flatten.all? { |scope| granted_scopes.include?(scope) }
|
||||
end
|
||||
|
||||
def granted_scopes
|
||||
parse_scopes(scope_value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= Integrations::Hook.find_by(account: @account, app_id: 'shopify', status: :enabled)
|
||||
end
|
||||
|
||||
def setup_shopify_context
|
||||
raise 'Shopify client credentials are missing.' if client_id.blank? || client_secret.blank?
|
||||
|
||||
ShopifyAPI::Context.setup(
|
||||
api_key: client_id,
|
||||
api_secret_key: client_secret,
|
||||
api_version: API_VERSION,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
is_embedded: true,
|
||||
is_private: false
|
||||
)
|
||||
end
|
||||
|
||||
def shopify_session
|
||||
ShopifyAPI::Auth::Session.new(shop: hook.reference_id, access_token: hook.access_token)
|
||||
end
|
||||
|
||||
def scope_value
|
||||
settings = hook&.settings || {}
|
||||
settings['scope'] || settings[:scope]
|
||||
end
|
||||
|
||||
def parse_scopes(raw_scope)
|
||||
raw_scope.to_s.split(',').map(&:strip).reject(&:blank?).uniq
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
class Integrations::Shopify::OrdersService
|
||||
REQUIRED_SCOPES = %w[read_customers read_orders].freeze
|
||||
MAX_LIMIT = 10
|
||||
MAX_LINE_ITEMS = 5
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
def orders_for_contact(email:, phone_number:, limit: MAX_LIMIT)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] orders_for_contact account_id=#{@account.id} " \
|
||||
"email_present=#{email.present?} phone_present=#{phone_number.present?} limit=#{limit}"
|
||||
)
|
||||
|
||||
identifiers = normalized_identifiers(email, phone_number)
|
||||
return missing_identifier_result if identifiers[:email].blank? && identifiers[:phone_number].blank?
|
||||
|
||||
client_result = @client_service.fetch_client
|
||||
return client_result unless client_result[:ok]
|
||||
|
||||
unless orders_scopes_granted?
|
||||
Rails.logger.warn(
|
||||
"[Integrations::Shopify::OrdersService] missing_scope account_id=#{@account.id} " \
|
||||
"required=#{REQUIRED_SCOPES.join(',')} granted=#{@client_service.granted_scopes.join(',')}"
|
||||
)
|
||||
return insufficient_scope_result
|
||||
end
|
||||
|
||||
customers = fetch_customers(client_result[:data][:client], identifiers[:email], identifiers[:phone_number])
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] customer_lookup account_id=#{@account.id} " \
|
||||
"customers_count=#{customers.length}"
|
||||
)
|
||||
return no_customer_result if customers.empty?
|
||||
|
||||
orders = fetch_orders(client_result[:data][:client], customers.first['id'], normalized_limit(limit))
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::OrdersService] orders_lookup account_id=#{@account.id} " \
|
||||
"customer_id=#{customers.first['id']} orders_count=#{orders.length}"
|
||||
)
|
||||
return failure(:no_results, 'No orders found for the customer.') if orders.empty?
|
||||
|
||||
success(
|
||||
orders: orders.map { |order| normalize_order(order, client_result[:data][:hook].reference_id) }
|
||||
)
|
||||
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||
Rails.logger.error("[Integrations::Shopify::OrdersService] Shopify error: #{e.message}")
|
||||
failure(:provider_error, 'Shopify order lookup failed.')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::OrdersService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Shopify order lookup failed.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
|
||||
private
|
||||
|
||||
def normalized_identifiers(email, phone_number)
|
||||
{
|
||||
email: email.to_s.strip,
|
||||
phone_number: phone_number.to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def missing_identifier_result
|
||||
Rails.logger.info("[Integrations::Shopify::OrdersService] missing_identifier account_id=#{@account.id}")
|
||||
failure(:missing_identifier, 'A contact email or phone number is required.')
|
||||
end
|
||||
|
||||
def no_customer_result
|
||||
Rails.logger.info("[Integrations::Shopify::OrdersService] no_customer_result account_id=#{@account.id}")
|
||||
failure(:no_results, 'No matching Shopify customer found.')
|
||||
end
|
||||
|
||||
def orders_scopes_granted?
|
||||
@client_service.scopes_include?(REQUIRED_SCOPES)
|
||||
end
|
||||
|
||||
def insufficient_scope_result
|
||||
failure(
|
||||
:insufficient_scope,
|
||||
'Shopify integration is missing customer/order read scopes.'
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_customers(client, email, phone_number)
|
||||
query_parts = []
|
||||
query_parts << "email:#{email}" if email.present?
|
||||
query_parts << "phone:#{phone_number}" if phone_number.present?
|
||||
|
||||
response = client.get(
|
||||
path: 'customers/search.json',
|
||||
query: {
|
||||
query: query_parts.join(' OR '),
|
||||
fields: 'id,email,phone'
|
||||
}
|
||||
)
|
||||
|
||||
response.body['customers'] || []
|
||||
end
|
||||
|
||||
def fetch_orders(client, customer_id, limit)
|
||||
response = client.get(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: customer_id,
|
||||
status: 'any',
|
||||
limit: limit,
|
||||
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||
}
|
||||
)
|
||||
|
||||
response.body['orders'] || []
|
||||
end
|
||||
|
||||
def normalize_order(order, shop_domain)
|
||||
{
|
||||
id: order['id'],
|
||||
name: order['name'],
|
||||
created_at: order['created_at'],
|
||||
total_price: order['total_price'],
|
||||
currency: order['currency'],
|
||||
financial_status: order['financial_status'],
|
||||
fulfillment_status: order['fulfillment_status'],
|
||||
line_items: normalize_line_items(order['line_items']),
|
||||
admin_url: admin_url(shop_domain, order['id'])
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_line_items(line_items)
|
||||
Array(line_items).first(MAX_LINE_ITEMS).map do |line_item|
|
||||
{
|
||||
title: line_item['title'],
|
||||
quantity: line_item['quantity'],
|
||||
price: line_item['price']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def admin_url(shop_domain, order_id)
|
||||
return nil if shop_domain.blank? || order_id.blank?
|
||||
|
||||
"https://#{shop_domain}/admin/orders/#{order_id}"
|
||||
end
|
||||
|
||||
def normalized_limit(limit)
|
||||
value = limit.to_i
|
||||
return MAX_LIMIT if value <= 0
|
||||
|
||||
[value, MAX_LIMIT].min
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
class Integrations::Shopify::ProductsService
|
||||
REQUIRED_SCOPE = 'read_products'.freeze
|
||||
MAX_LIMIT = 10
|
||||
FALLBACK_MULTIPLIER = 5
|
||||
MAX_FALLBACK_LIMIT = 50
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
@client_service = Integrations::Shopify::ClientService.new(account: account)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def search_products(query:, limit: MAX_LIMIT)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] search_products account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} limit=#{limit}"
|
||||
)
|
||||
return no_products_result(query) if query.blank?
|
||||
|
||||
client_result = @client_service.fetch_client
|
||||
return client_result unless client_result[:ok]
|
||||
|
||||
unless product_scope_granted?
|
||||
Rails.logger.warn(
|
||||
"[Integrations::Shopify::ProductsService] missing_scope account_id=#{@account.id} " \
|
||||
"required=#{REQUIRED_SCOPE} granted=#{@client_service.granted_scopes.join(',')}"
|
||||
)
|
||||
return failure(:insufficient_scope, 'Shopify integration is missing read_products scope.')
|
||||
end
|
||||
|
||||
products = fetch_products(client_result[:data][:client], query, normalized_limit(limit))
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] shopify_response account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} products_count=#{products.length}"
|
||||
)
|
||||
|
||||
return no_products_result(query) if products.empty?
|
||||
|
||||
success(
|
||||
products: products.map { |product| normalize_product(product, client_result[:data][:hook].reference_id) }
|
||||
)
|
||||
rescue ShopifyAPI::Errors::HttpResponseError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ProductsService] Shopify error: #{e.message}")
|
||||
failure(:provider_error, 'Shopify product search failed.')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Integrations::Shopify::ProductsService] #{e.class}: #{e.message}")
|
||||
failure(:provider_error, 'Shopify product search failed.')
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
|
||||
private
|
||||
|
||||
def product_scope_granted?
|
||||
@client_service.scopes_include?(REQUIRED_SCOPE)
|
||||
end
|
||||
|
||||
def no_products_result(query)
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] no_results account_id=#{@account.id} query=#{query.inspect}"
|
||||
)
|
||||
failure(:no_results, 'No products found for the provided query.')
|
||||
end
|
||||
|
||||
def fetch_products(client, query, limit)
|
||||
products = fetch_products_by_title(client, query, limit)
|
||||
return products if products.any?
|
||||
|
||||
fallback_limit = [(limit * FALLBACK_MULTIPLIER), MAX_FALLBACK_LIMIT].min
|
||||
Rails.logger.info(
|
||||
"[Integrations::Shopify::ProductsService] fallback_keyword_search account_id=#{@account.id} " \
|
||||
"query=#{query.inspect} fallback_limit=#{fallback_limit}"
|
||||
)
|
||||
|
||||
fallback_products = fetch_active_products(client, fallback_limit)
|
||||
filter_products_by_keyword(fallback_products, query).first(limit)
|
||||
end
|
||||
|
||||
def fetch_products_by_title(client, query, limit)
|
||||
response = client.get(
|
||||
path: 'products.json',
|
||||
query: product_query(limit: limit, title: query)
|
||||
)
|
||||
response.body['products'] || []
|
||||
end
|
||||
|
||||
def fetch_active_products(client, limit)
|
||||
response = client.get(
|
||||
path: 'products.json',
|
||||
query: product_query(limit: limit)
|
||||
)
|
||||
response.body['products'] || []
|
||||
end
|
||||
|
||||
def product_query(limit:, title: nil)
|
||||
{
|
||||
title: title,
|
||||
status: 'active',
|
||||
limit: limit,
|
||||
fields: 'id,title,vendor,product_type,handle,variants'
|
||||
}.compact
|
||||
end
|
||||
|
||||
def filter_products_by_keyword(products, query)
|
||||
keyword = query.to_s.downcase.strip
|
||||
return [] if keyword.blank?
|
||||
|
||||
products.select do |product|
|
||||
searchable_text = [product['title'], product['vendor'], product['product_type']].compact.join(' ').downcase
|
||||
searchable_text.include?(keyword)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_product(product, shop_domain)
|
||||
variants = Array(product['variants'])
|
||||
first_variant = variants.first || {}
|
||||
|
||||
{
|
||||
id: product['id'],
|
||||
title: product['title'],
|
||||
vendor: product['vendor'],
|
||||
product_type: product['product_type'],
|
||||
handle: product['handle'],
|
||||
storefront_url: storefront_url(shop_domain, product['handle']),
|
||||
price: first_variant['price'],
|
||||
availability: availability_summary(variants)
|
||||
}
|
||||
end
|
||||
|
||||
def storefront_url(shop_domain, handle)
|
||||
return nil if shop_domain.blank? || handle.blank?
|
||||
|
||||
"https://#{shop_domain}/products/#{handle}"
|
||||
end
|
||||
|
||||
def availability_summary(variants)
|
||||
return 'Out of stock' if variants.empty?
|
||||
|
||||
available_count = variants.count { |variant| variant_available?(variant) }
|
||||
return 'Out of stock' if available_count.zero?
|
||||
return 'In stock' if available_count == variants.size
|
||||
|
||||
"Partially in stock (#{available_count}/#{variants.size} variants)"
|
||||
end
|
||||
|
||||
def variant_available?(variant)
|
||||
return variant['available'] if [true, false].include?(variant['available'])
|
||||
|
||||
quantity = variant['inventory_quantity']
|
||||
return quantity.to_i.positive? unless quantity.nil?
|
||||
|
||||
variant['inventory_policy'] == 'continue'
|
||||
end
|
||||
|
||||
def normalized_limit(limit)
|
||||
value = limit.to_i
|
||||
return MAX_LIMIT if value <= 0
|
||||
|
||||
[value, MAX_LIMIT].min
|
||||
end
|
||||
|
||||
def success(data)
|
||||
{ ok: true, data: data }
|
||||
end
|
||||
|
||||
def failure(code, message)
|
||||
{ ok: false, error: { code: code, message: message } }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
# Captain + Shopify Upgrade — Investigation and Implementation
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Document the investigation, fixes, and validation steps for Shopify-powered Captain flows in Chatwoot, focused on:
|
||||
|
||||
- Product search reliability
|
||||
- Order lookup reliability
|
||||
- Runtime observability (logs)
|
||||
|
||||
This guide reflects the final state where **order lookup is by contact email/phone only** (not by order ID).
|
||||
|
||||
## 2. Reported Symptoms
|
||||
|
||||
### Product flow
|
||||
|
||||
- Captain returned incomplete/incorrect product answers for known products.
|
||||
- Shopify REST API warning was observed for `products.json` deprecation.
|
||||
|
||||
### Order flow
|
||||
|
||||
- Customer had a valid Shopify order visible in UI.
|
||||
- Captain still responded with:
|
||||
- `I need the contact email or phone number...`
|
||||
- Logs showed:
|
||||
- `shopify_get_orders_identity_resolved ... email_present: false, phone_present: false`
|
||||
- `missing_identifier`
|
||||
|
||||
## 3. Root Cause
|
||||
|
||||
For order lookup, Captain tool identity was initially resolved from `state[:contact]` only.
|
||||
|
||||
- In the failing conversation, contact state had:
|
||||
- `contact.email = nil`
|
||||
- `contact.phone_number = nil`
|
||||
- User shared email in message text, but tool did not use message text/history for identity.
|
||||
|
||||
## 4. Final Implementation Summary
|
||||
|
||||
## 4.1 Product search improvements
|
||||
|
||||
- Added stronger logging around Shopify client initialization and product search.
|
||||
- Added fallback matching strategy in product search when strict title filtering produced empty results.
|
||||
|
||||
## 4.2 Order lookup improvements (final behavior)
|
||||
|
||||
- Kept Shopify order lookup API in service as:
|
||||
- `orders_for_contact(email:, phone_number:, limit:)`
|
||||
- Added robust identity resolution for Captain order tool:
|
||||
- Explicit tool args (if provided by model)
|
||||
- Contact state
|
||||
- Current user input text (`captain_v2_trace_current_input`)
|
||||
- User trace history (`captain_v2_trace_input`) for confirmation turns like `Yes`
|
||||
- Final outcome: order lookup remains **email/phone based**, but now survives missing contact fields when identity exists in conversation text.
|
||||
|
||||
## 4.3 Error handling and UX
|
||||
|
||||
- Missing identifier message is:
|
||||
- `I need the contact email or phone number to look up Shopify orders.`
|
||||
- Domain/provider errors are mapped to safe user-facing messages.
|
||||
|
||||
## 4.4 Prompt guidance
|
||||
|
||||
- Captain prompt guidance for Shopify order queries remains generic for order lookup via Shopify order tool.
|
||||
|
||||
## 5. Key Files
|
||||
|
||||
- Service:
|
||||
- `app/services/integrations/shopify/client_service.rb`
|
||||
- `app/services/integrations/shopify/products_service.rb`
|
||||
- `app/services/integrations/shopify/orders_service.rb`
|
||||
- Captain tools:
|
||||
- `enterprise/lib/captain/tools/shopify_base_tool.rb`
|
||||
- `enterprise/lib/captain/tools/shopify_search_products_tool.rb`
|
||||
- `enterprise/lib/captain/tools/shopify_get_orders_tool.rb`
|
||||
- Prompt:
|
||||
- `enterprise/lib/captain/prompts/assistant.liquid`
|
||||
- Specs:
|
||||
- `spec/services/integrations/shopify/products_service_spec.rb`
|
||||
- `spec/services/integrations/shopify/orders_service_spec.rb`
|
||||
- `spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb`
|
||||
- `spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb`
|
||||
- `spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb`
|
||||
|
||||
## 6. QA Checklist
|
||||
|
||||
### Product search
|
||||
|
||||
1. Ask for a known product keyword (example: `Do you have snowboard?`).
|
||||
2. Verify Captain returns matching Shopify products with links.
|
||||
3. Check logs for:
|
||||
- `shopify_search_products_requested`
|
||||
- `ProductsService ... search_products`
|
||||
- fallback log path when strict title match is empty
|
||||
|
||||
### Order lookup (email/phone)
|
||||
|
||||
1. Ensure contact sidebar may have empty email/phone.
|
||||
2. Ask: `Where is my order russel.winfield@example.com?`
|
||||
3. Verify Captain resolves identity and attempts Shopify lookup.
|
||||
4. Confirmation flow:
|
||||
- User provides email in earlier turn
|
||||
- Later replies only `Yes`
|
||||
- Verify tool still resolves email from trace history and proceeds
|
||||
5. Check logs for:
|
||||
- `shopify_get_orders_requested`
|
||||
- `shopify_get_orders_identity_resolved` with `email_present: true` or `phone_present: true`
|
||||
- no `missing_identifier` when identity exists in message/history
|
||||
|
||||
## 7. Spec/Lint Commands Used
|
||||
|
||||
```bash
|
||||
bundle exec rspec spec/services/integrations/shopify/orders_service_spec.rb \
|
||||
spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb \
|
||||
spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb
|
||||
```
|
||||
|
||||
```bash
|
||||
bundle exec rubocop \
|
||||
app/services/integrations/shopify/orders_service.rb \
|
||||
enterprise/lib/captain/tools/shopify_base_tool.rb \
|
||||
enterprise/lib/captain/tools/shopify_get_orders_tool.rb \
|
||||
spec/services/integrations/shopify/orders_service_spec.rb \
|
||||
spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb
|
||||
```
|
||||
|
||||
## 8. Known Non-Blocking Noise
|
||||
|
||||
- Sidekiq scheduled `Discord::PollAllChannelsJob` may fail with:
|
||||
- `NameError: uninitialized constant Discord`
|
||||
- This is unrelated to Shopify Captain product/order behavior.
|
||||
|
||||
## 9. Rollback Guidance
|
||||
|
||||
If required, rollback can be done by reverting Shopify-related tool/service commits only:
|
||||
|
||||
- Revert the Shopify service/tool/spec files listed in Section 5.
|
||||
- Re-run the spec and rubocop commands in Section 7.
|
||||
|
||||
## 10. Missed in This Iteration (Planned for Next Version)
|
||||
|
||||
The following items were identified during implementation/testing but intentionally deferred:
|
||||
|
||||
1. Shopify order lookup by order ID (optional mode)
|
||||
- We validated this path technically, but final behavior in this release is email/phone-only.
|
||||
- Next version plan:
|
||||
- Add `order_id` lookup behind a feature flag.
|
||||
- Keep email/phone as default to avoid accidental misrouting.
|
||||
|
||||
2. Contact identity persistence from conversation
|
||||
- Current fix resolves identity from message/trace at runtime.
|
||||
- Next version plan:
|
||||
- Optionally persist verified email/phone back to contact profile (with guardrails), so subsequent turns need fewer recoveries.
|
||||
|
||||
3. Shopify API modernization (REST -> GraphQL)
|
||||
- Logs still show REST deprecation warnings for product endpoints.
|
||||
- Next version plan:
|
||||
- Move product/order reads to Shopify GraphQL Admin API.
|
||||
- Preserve response contracts expected by Captain tools.
|
||||
|
||||
4. End-to-end regression suite for conversational identity flows
|
||||
- Current tests are service + tool unit specs.
|
||||
- Next version plan:
|
||||
- Add integration-level specs for real multi-turn conversation flows:
|
||||
- email in first turn + `Yes` confirmation turn
|
||||
- phone-only lookup
|
||||
- empty identity -> prompt for identifier
|
||||
|
||||
5. Better operator-facing diagnostics
|
||||
- Logs are present, but triage still needs manual correlation.
|
||||
- Next version plan:
|
||||
- Add structured log keys and dashboards for:
|
||||
- identity source used (`contact`, `current_input`, `trace_history`)
|
||||
- tool success/failure reason distribution
|
||||
- Shopify error categories by account
|
||||
|
||||
6. Non-Shopify scheduled job noise
|
||||
- `Discord::PollAllChannelsJob` `NameError` is unrelated but pollutes logs.
|
||||
- Next version plan:
|
||||
- Gate/disable scheduler for disconnected integrations in dev/test to reduce noise during Captain investigations.
|
||||
@@ -30,6 +30,16 @@
|
||||
description: 'Search FAQ responses using semantic similarity'
|
||||
icon: 'search'
|
||||
|
||||
- id: shopify_search_products
|
||||
title: 'Shopify: Search Products'
|
||||
description: 'Search products in the connected Shopify store'
|
||||
icon: 'shopping-bag'
|
||||
|
||||
- id: shopify_get_orders
|
||||
title: 'Shopify: Get Orders'
|
||||
description: "Look up a customer's orders from Shopify"
|
||||
icon: 'shopping-cart'
|
||||
|
||||
- id: resolve_conversation
|
||||
title: 'Resolve Conversation'
|
||||
description: 'Resolve a conversation when the issue has been addressed'
|
||||
|
||||
@@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
SHOPIFY_TOOL_IDS = %w[shopify_search_products shopify_get_orders].freeze
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
@@ -52,6 +53,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
tools = filter_shopify_tool_metadata(tools)
|
||||
|
||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
@@ -92,10 +94,25 @@ class Captain::Assistant < ApplicationRecord
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[
|
||||
tools = [
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
]
|
||||
|
||||
if shopify_tools_enabled_for_v2?
|
||||
tools.concat(
|
||||
SHOPIFY_TOOL_IDS.filter_map do |tool_id|
|
||||
tool_class = self.class.resolve_tool_class(tool_id)
|
||||
tool_class&.new(self)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
tools
|
||||
end
|
||||
|
||||
def shopify_tools_enabled_for_v2?
|
||||
account.feature_enabled?('captain_integration_v2') && shopify_connected?
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
@@ -118,4 +135,14 @@ class Captain::Assistant < ApplicationRecord
|
||||
def default_avatar_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||
end
|
||||
|
||||
def filter_shopify_tool_metadata(tools)
|
||||
return tools if shopify_tools_enabled_for_v2?
|
||||
|
||||
tools.reject { |tool| SHOPIFY_TOOL_IDS.include?(tool[:id]) }
|
||||
end
|
||||
|
||||
def shopify_connected?
|
||||
Integrations::Hook.exists?(account_id: account_id, app_id: 'shopify', status: :enabled)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ module Concerns::CaptainToolsHelpers
|
||||
# @param tool_id [String] The snake_case tool identifier
|
||||
# @return [Class, nil] The tool class if found, nil if not resolvable
|
||||
def resolve_tool_class(tool_id)
|
||||
class_name = "Captain::Tools::#{tool_id.classify}Tool"
|
||||
class_name = "Captain::Tools::#{tool_id.camelize}Tool"
|
||||
class_name.safe_constantize
|
||||
end
|
||||
|
||||
|
||||
@@ -66,9 +66,11 @@ If unclear, ask clarifying questions to determine if a scenario applies:
|
||||
If no specialized scenario clearly matches, handle it yourself in the following way
|
||||
|
||||
### For Questions and Information Requests
|
||||
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
|
||||
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
|
||||
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
|
||||
1. **For Shopify product queries**: Use `captain--tools--shopify_search_products` first to find matching products
|
||||
2. **For Shopify order status/lookup queries**: Use `captain--tools--shopify_get_orders` first to check customer orders
|
||||
3. **For general factual questions**: Use `captain--tools--faq_lookup` tool to search for relevant information
|
||||
4. **If not found in tools**: Ask clarifying questions to gather more information
|
||||
5. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
|
||||
|
||||
### For Complex or Unclear Requests
|
||||
1. **Ask clarifying questions**: Gather more information if needed
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
class Captain::Tools::ShopifyBaseTool < Captain::Tools::BasePublicTool
|
||||
SHOPIFY_APP_ID = 'shopify'.freeze
|
||||
V2_FEATURE_FLAG = 'captain_integration_v2'.freeze
|
||||
EMAIL_REGEX = /\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i
|
||||
PHONE_REGEX = /(?:\+\d[\d\s().-]{7,}\d|\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4})/
|
||||
|
||||
def active?
|
||||
v2_enabled? && shopify_connected?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def v2_enabled?
|
||||
@assistant.account.feature_enabled?(V2_FEATURE_FLAG)
|
||||
end
|
||||
|
||||
def shopify_connected?
|
||||
Integrations::Hook.exists?(account_id: @assistant.account_id, app_id: SHOPIFY_APP_ID, status: :enabled)
|
||||
end
|
||||
|
||||
def eligibility_error_message
|
||||
unless v2_enabled?
|
||||
log_tool_usage('shopify_tool_not_eligible', { reason: 'captain_v2_disabled' })
|
||||
return 'This Shopify tool is available only for Captain V2.'
|
||||
end
|
||||
|
||||
unless shopify_connected?
|
||||
log_tool_usage('shopify_tool_not_eligible', { reason: 'shopify_not_connected' })
|
||||
return 'Shopify integration is not connected for this account.'
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_contact_identity(state, override_identity = {})
|
||||
inferred_identity = extract_identity_from_current_input(state)
|
||||
history_identity = extract_identity_from_trace_history(state)
|
||||
|
||||
{
|
||||
email: resolve_identity_value(:email, override_identity, state, inferred_identity, history_identity),
|
||||
phone_number: resolve_identity_value(:phone_number, override_identity, state, inferred_identity, history_identity)
|
||||
}
|
||||
end
|
||||
|
||||
def format_domain_error(error)
|
||||
case error&.dig(:code)
|
||||
when :not_connected
|
||||
'Shopify integration is not connected. Please connect Shopify and try again.'
|
||||
when :insufficient_scope
|
||||
'Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.'
|
||||
when :missing_identifier
|
||||
'I need the contact email or phone number to look up Shopify orders.'
|
||||
when :no_results
|
||||
error[:message].presence || 'No matching Shopify records were found.'
|
||||
else
|
||||
'I could not fetch Shopify data right now. Please try again shortly.'
|
||||
end
|
||||
end
|
||||
|
||||
def extract_identity_from_current_input(state)
|
||||
current_input = extract_current_input_text(state)
|
||||
return empty_identity if current_input.blank?
|
||||
|
||||
extract_identity_from_text(current_input)
|
||||
end
|
||||
|
||||
def extract_current_input_text(state)
|
||||
raw_input = state&.dig(:captain_v2_trace_current_input).to_s
|
||||
return '' if raw_input.blank?
|
||||
|
||||
extract_trace_content_text(parse_trace_payload(raw_input))
|
||||
end
|
||||
|
||||
def extract_identity_from_trace_history(state)
|
||||
trace_input = state&.dig(:captain_v2_trace_input).to_s
|
||||
parsed_input = parse_trace_payload(trace_input)
|
||||
return empty_identity unless parsed_input.is_a?(Array)
|
||||
|
||||
user_messages = parsed_input.reverse.filter_map { |entry| trace_user_text(entry) }
|
||||
first_identity_from_messages(user_messages)
|
||||
end
|
||||
|
||||
def extract_trace_content_text(content)
|
||||
return '' if content.blank?
|
||||
return content.to_s unless content.is_a?(Array)
|
||||
|
||||
content.filter_map { |part| part['text'] if part.is_a?(Hash) && part['type'] == 'text' }.join(' ')
|
||||
end
|
||||
|
||||
def trace_user_text(entry)
|
||||
return unless entry.is_a?(Hash) && entry['role'] == 'user'
|
||||
|
||||
extract_trace_content_text(entry['content']).presence
|
||||
end
|
||||
|
||||
def first_identity_from_messages(messages)
|
||||
identity = empty_identity
|
||||
|
||||
messages.each do |message|
|
||||
identity[:email] ||= normalize_email(message)
|
||||
identity[:phone_number] ||= normalize_phone(message[PHONE_REGEX])
|
||||
break if identity.values.all?(&:present?)
|
||||
end
|
||||
|
||||
identity
|
||||
end
|
||||
|
||||
def extract_identity_from_text(text)
|
||||
{
|
||||
email: normalize_email(text),
|
||||
phone_number: normalize_phone(text[PHONE_REGEX])
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_identity_value(key, override_identity, state, inferred_identity, history_identity)
|
||||
[
|
||||
normalized_identity_value(key, override_identity[key]),
|
||||
normalized_identity_value(key, state&.dig(:contact, key)),
|
||||
normalized_identity_value(key, inferred_identity[key]),
|
||||
normalized_identity_value(key, history_identity[key])
|
||||
].find(&:present?)
|
||||
end
|
||||
|
||||
def normalized_identity_value(key, value)
|
||||
return normalize_email(value) if key == :email
|
||||
|
||||
normalize_phone(value)
|
||||
end
|
||||
|
||||
def empty_identity
|
||||
{ email: nil, phone_number: nil }
|
||||
end
|
||||
|
||||
def parse_trace_payload(raw_input)
|
||||
return raw_input unless raw_input.strip.start_with?('[', '{')
|
||||
|
||||
JSON.parse(raw_input)
|
||||
rescue JSON::ParserError
|
||||
raw_input
|
||||
end
|
||||
|
||||
def normalize_email(raw_value)
|
||||
value = raw_value.to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
value[EMAIL_REGEX]
|
||||
end
|
||||
|
||||
def normalize_phone(raw_value)
|
||||
value = raw_value.to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
normalized = value.gsub(/[^\d+]/, '')
|
||||
return nil unless normalized.match?(/\A\+?\d{7,15}\z/)
|
||||
|
||||
normalized
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
class Captain::Tools::ShopifyGetOrdersTool < Captain::Tools::ShopifyBaseTool
|
||||
description "Look up a customer's orders from Shopify"
|
||||
param :email, type: 'string', desc: 'Customer email used for order lookup', required: false
|
||||
param :phone_number, type: 'string', desc: 'Customer phone number used for order lookup', required: false
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def perform(tool_context, email: nil, phone_number: nil)
|
||||
log_tool_usage('shopify_get_orders_requested', { email_present: email.present?, phone_present: phone_number.present? })
|
||||
return eligibility_error_message if eligibility_error_message
|
||||
|
||||
identity = resolve_contact_identity(tool_context&.state, { email: email, phone_number: phone_number })
|
||||
log_tool_usage(
|
||||
'shopify_get_orders_identity_resolved',
|
||||
{ email_present: identity[:email].present?, phone_present: identity[:phone_number].present? }
|
||||
)
|
||||
|
||||
result = orders_service.orders_for_contact(email: identity[:email], phone_number: identity[:phone_number], limit: 10)
|
||||
unless result[:ok]
|
||||
log_tool_usage('shopify_get_orders_failed', { error: result[:error] })
|
||||
return format_domain_error(result[:error])
|
||||
end
|
||||
|
||||
log_tool_usage('shopify_get_orders_success', { orders_count: result[:data][:orders].length })
|
||||
|
||||
format_orders(result[:data][:orders])
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||
'I could not fetch Shopify orders right now. Please try again shortly.'
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
private
|
||||
|
||||
def orders_service
|
||||
@orders_service ||= Integrations::Shopify::OrdersService.new(account: @assistant.account)
|
||||
end
|
||||
|
||||
def format_orders(orders)
|
||||
lines = ["Found #{orders.length} Shopify orders:"]
|
||||
|
||||
orders.each_with_index do |order, index|
|
||||
lines << order_line(index, order)
|
||||
lines << " Items: #{line_items_summary(order[:line_items])}"
|
||||
end
|
||||
|
||||
lines.join("\n")
|
||||
end
|
||||
|
||||
def order_line(index, order)
|
||||
[
|
||||
"#{index + 1}. #{order[:name]}",
|
||||
"Date: #{order[:created_at]}",
|
||||
"Total: #{formatted_total(order)}",
|
||||
"Payment: #{order[:financial_status]}",
|
||||
"Fulfillment: #{order[:fulfillment_status]}",
|
||||
"Admin: #{order[:admin_url]}"
|
||||
].join(' | ')
|
||||
end
|
||||
|
||||
def line_items_summary(line_items)
|
||||
items = Array(line_items)
|
||||
return 'N/A' if items.empty?
|
||||
|
||||
items.map { |item| "#{item[:quantity]}x #{item[:title]}" }.join(', ')
|
||||
end
|
||||
|
||||
def formatted_total(order)
|
||||
return 'N/A' if order[:total_price].blank?
|
||||
|
||||
[order[:currency], order[:total_price]].compact.join(' ')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
class Captain::Tools::ShopifySearchProductsTool < Captain::Tools::ShopifyBaseTool
|
||||
description 'Search products in the connected Shopify store'
|
||||
param :query, type: 'string', desc: 'Search query for product name or keywords'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('shopify_search_products_requested', { query: query })
|
||||
return eligibility_error_message if eligibility_error_message
|
||||
|
||||
result = products_service.search_products(query: query, limit: 10)
|
||||
unless result[:ok]
|
||||
log_tool_usage('shopify_search_products_failed', { query: query, error: result[:error] })
|
||||
return format_domain_error(result[:error])
|
||||
end
|
||||
|
||||
log_tool_usage('shopify_search_products_success', { query: query, products_count: result[:data][:products].length })
|
||||
|
||||
format_products(result[:data][:products])
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||
'I could not fetch Shopify products right now. Please try again shortly.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def products_service
|
||||
@products_service ||= Integrations::Shopify::ProductsService.new(account: @assistant.account)
|
||||
end
|
||||
|
||||
def format_products(products)
|
||||
lines = ["Found #{products.length} matching Shopify products:"]
|
||||
|
||||
products.each_with_index do |product, index|
|
||||
lines << product_line(index, product)
|
||||
end
|
||||
|
||||
lines.join("\n")
|
||||
end
|
||||
|
||||
def product_line(index, product)
|
||||
[
|
||||
"#{index + 1}. #{product[:title]}",
|
||||
"Price: #{formatted_price(product[:price])}",
|
||||
"Availability: #{product[:availability]}",
|
||||
"URL: #{product[:storefront_url]}"
|
||||
].join(' | ')
|
||||
end
|
||||
|
||||
def formatted_price(price)
|
||||
return 'N/A' if price.blank?
|
||||
|
||||
price.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
# `shopify_advanced.md` + Implementation Runbook (Enterprise, Captain V2, Products+Orders)
|
||||
|
||||
## Summary
|
||||
Implement advanced Shopify support for Captain as **Enterprise + Captain V2 only**, with **phase 1 limited to product search and order lookup**.
|
||||
No Captain V1 work, no cart/checkout work in this phase.
|
||||
|
||||
## Deliverables
|
||||
1. Root doc file: `shopify_advanced.md` (this plan content).
|
||||
2. Backend Shopify service layer (shared, read-only).
|
||||
3. Two new Captain built-in tools:
|
||||
- `shopify_search_products`
|
||||
- `shopify_get_orders`
|
||||
4. Tool exposure/runtime gating to Captain V2 + connected Shopify only.
|
||||
5. Scope update: add `read_products`.
|
||||
6. Specs for services, tools, and gating.
|
||||
|
||||
## Important Interface Changes
|
||||
1. New Captain tool IDs:
|
||||
- `shopify_search_products`
|
||||
- `shopify_get_orders`
|
||||
2. Shopify required scopes update:
|
||||
- add `read_products` to `REQUIRED_SCOPES`.
|
||||
3. No new public REST endpoints for Captain.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1) Shared Shopify services
|
||||
1. `app/services/integrations/shopify/client_service.rb`
|
||||
2. `app/services/integrations/shopify/products_service.rb`
|
||||
3. `app/services/integrations/shopify/orders_service.rb`
|
||||
|
||||
### 2) Enterprise Captain tools
|
||||
1. `enterprise/lib/captain/tools/shopify_base_tool.rb`
|
||||
2. `enterprise/lib/captain/tools/shopify_search_products_tool.rb`
|
||||
3. `enterprise/lib/captain/tools/shopify_get_orders_tool.rb`
|
||||
|
||||
### 3) Specs
|
||||
1. `spec/services/integrations/shopify/products_service_spec.rb`
|
||||
2. `spec/services/integrations/shopify/orders_service_spec.rb`
|
||||
3. `spec/enterprise/lib/captain/tools/shopify_base_tool_spec.rb`
|
||||
4. `spec/enterprise/lib/captain/tools/shopify_search_products_tool_spec.rb`
|
||||
5. `spec/enterprise/lib/captain/tools/shopify_get_orders_tool_spec.rb`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. [`/Users/muhsink/Documents/chatwoot/app/helpers/shopify/integration_helper.rb`](/Users/muhsink/Documents/chatwoot/app/helpers/shopify/integration_helper.rb)
|
||||
- Add `read_products` to `REQUIRED_SCOPES`.
|
||||
|
||||
2. [`/Users/muhsink/Documents/chatwoot/config/agents/tools.yml`](/Users/muhsink/Documents/chatwoot/config/agents/tools.yml)
|
||||
- Register the 2 new tools with title/description/icon.
|
||||
|
||||
3. [`/Users/muhsink/Documents/chatwoot/enterprise/app/models/captain/assistant.rb`](/Users/muhsink/Documents/chatwoot/enterprise/app/models/captain/assistant.rb)
|
||||
- Add helper `shopify_tools_enabled_for_v2?`.
|
||||
- Auto-append Shopify tools in `agent_tools` when eligible.
|
||||
- Filter tool metadata in `available_agent_tools` unless eligible.
|
||||
|
||||
4. [`/Users/muhsink/Documents/chatwoot/enterprise/lib/captain/prompts/assistant.liquid`](/Users/muhsink/Documents/chatwoot/enterprise/lib/captain/prompts/assistant.liquid)
|
||||
- Add short instruction: use Shopify tools for product/order queries before FAQ fallback.
|
||||
|
||||
5. Optional refactor only (no contract change):
|
||||
- [`/Users/muhsink/Documents/chatwoot/app/controllers/api/v1/accounts/integrations/shopify_controller.rb`](/Users/muhsink/Documents/chatwoot/app/controllers/api/v1/accounts/integrations/shopify_controller.rb)
|
||||
- Reuse `OrdersService` internally for consistency.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### A. `ClientService`
|
||||
Responsibilities:
|
||||
1. Load connected hook (`app_id: 'shopify'`, status enabled).
|
||||
2. Build Shopify REST client from hook token.
|
||||
3. Parse granted scopes from hook settings.
|
||||
4. Return normalized failure objects for:
|
||||
- `:not_connected`
|
||||
- `:provider_error`
|
||||
|
||||
### B. `ProductsService`
|
||||
Method:
|
||||
- `search_products(query:, limit: 10)`
|
||||
|
||||
Behavior:
|
||||
1. Validate connection + `read_products`.
|
||||
2. Fetch active products by title query.
|
||||
3. Normalize output:
|
||||
- `id`, `title`, `vendor`, `product_type`, `handle`, `storefront_url`
|
||||
- first variant `price`
|
||||
- availability summary from variants.
|
||||
4. Return normalized result/error:
|
||||
- `:insufficient_scope`, `:no_results`, `:provider_error`.
|
||||
|
||||
### C. `OrdersService`
|
||||
Method:
|
||||
- `orders_for_contact(email:, phone_number:, limit: 10)`
|
||||
|
||||
Behavior:
|
||||
1. Require at least one identifier (`email` or `phone_number`).
|
||||
2. Validate connection + scopes `read_customers` and `read_orders`.
|
||||
3. Search customer by `email OR phone`.
|
||||
4. Fetch orders for first matched customer.
|
||||
5. Normalize output:
|
||||
- `id`, `name`, `created_at`, `total_price`, `currency`
|
||||
- `financial_status`, `fulfillment_status`
|
||||
- `line_items` (capped)
|
||||
- `admin_url`.
|
||||
6. Return normalized result/error:
|
||||
- `:missing_identifier`, `:no_results`, `:provider_error`.
|
||||
|
||||
### D. `ShopifyBaseTool`
|
||||
Common behavior:
|
||||
1. Extend `BasePublicTool`.
|
||||
2. `active?` true only when:
|
||||
- `captain_integration_v2` enabled
|
||||
- Shopify hook enabled for account.
|
||||
3. Common helpers:
|
||||
- `v2_enabled?`
|
||||
- `shopify_connected?`
|
||||
- `resolve_contact_identity(tool_context.state)`
|
||||
- scope guard + standardized reconnect message.
|
||||
4. Tool-safe deterministic messages, no exceptions leaked.
|
||||
|
||||
### E. `shopify_search_products` tool
|
||||
Params:
|
||||
1. `query` (required string).
|
||||
|
||||
Behavior:
|
||||
1. Call `ProductsService#search_products`.
|
||||
2. Format compact plain text list (max 10):
|
||||
- title
|
||||
- price
|
||||
- availability
|
||||
- product URL.
|
||||
3. Deterministic fallback messages for each domain error.
|
||||
|
||||
### F. `shopify_get_orders` tool
|
||||
Params:
|
||||
1. none (phase 1).
|
||||
|
||||
Behavior:
|
||||
1. Resolve contact email/phone from conversation state.
|
||||
2. Call `OrdersService#orders_for_contact`.
|
||||
3. Format compact plain text list (max 10):
|
||||
- order name/date/total/status/admin URL
|
||||
- top line items.
|
||||
4. Deterministic fallback messages for each domain error.
|
||||
|
||||
## Gating Rules (Decision-Complete)
|
||||
1. Tool visibility in UI:
|
||||
- Hidden unless `captain_integration_v2` and Shopify connected.
|
||||
2. Tool execution:
|
||||
- If invoked when ineligible, return non-execution message (no crash).
|
||||
3. Scenario validity:
|
||||
- Existing `available_tool_ids`-based validation remains source of truth.
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Service specs
|
||||
1. Product search success with normalized fields.
|
||||
2. Product no results.
|
||||
3. Product missing scope.
|
||||
4. Orders success by email.
|
||||
5. Orders success by phone.
|
||||
6. Orders missing identifier.
|
||||
7. Orders no matching customer.
|
||||
8. Provider error mapping for both services.
|
||||
|
||||
### Tool specs
|
||||
1. `active?` false when V2 disabled.
|
||||
2. `active?` false when Shopify disconnected.
|
||||
3. Product tool success formatting.
|
||||
4. Product tool missing scope message.
|
||||
5. Orders tool success formatting.
|
||||
6. Orders tool missing identity message.
|
||||
7. Orders tool no-result message.
|
||||
8. Provider error safe messaging.
|
||||
|
||||
### Model/gating specs
|
||||
1. `available_agent_tools` hides Shopify tools unless eligible.
|
||||
2. `agent_tools` includes Shopify tools only when eligible.
|
||||
|
||||
### Regression
|
||||
1. Existing Shopify integration request specs still pass.
|
||||
2. Existing Captain tools unaffected.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. With V2 enabled + Shopify connected + proper scopes:
|
||||
- “Do you have sneakers?” triggers `shopify_search_products` and returns relevant products with links.
|
||||
- “Where is my order?” triggers `shopify_get_orders` and returns recent order details.
|
||||
2. With older Shopify install missing product scope:
|
||||
- Product tool returns reconnect guidance; no crash.
|
||||
3. With V2 off or no Shopify connection:
|
||||
- Shopify tools are not shown and not executed.
|
||||
|
||||
## Assumptions
|
||||
1. Enterprise-only feature is acceptable.
|
||||
2. Captain V2 only.
|
||||
3. Phase 1 excludes abandoned checkouts/cart.
|
||||
4. Plain text output only.
|
||||
5. English copy updates only where needed.
|
||||
|
||||
## `shopify_advanced.md` Content
|
||||
Use this entire document as the initial content of `shopify_advanced.md` at repo root.
|
||||
@@ -0,0 +1,80 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::ShopifyBaseTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool_class) do
|
||||
Class.new(described_class) do
|
||||
def perform(*)
|
||||
'ok'
|
||||
end
|
||||
end
|
||||
end
|
||||
let(:tool) { tool_class.new(assistant) }
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns false when captain v2 is disabled' do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
|
||||
expect(tool.active?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when shopify is disconnected' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
|
||||
expect(tool.active?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when v2 is enabled and shopify is connected' do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolve_contact_identity' do
|
||||
it 'returns email and phone from state' do
|
||||
identity = tool.send(:resolve_contact_identity, { contact: { email: 'john@example.com', phone_number: '+15551234567' } })
|
||||
|
||||
expect(identity).to eq({ email: 'john@example.com', phone_number: '+15551234567' })
|
||||
end
|
||||
|
||||
it 'falls back to extracting email from current input when contact identity is missing' do
|
||||
identity = tool.send(
|
||||
:resolve_contact_identity,
|
||||
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
|
||||
)
|
||||
|
||||
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
|
||||
end
|
||||
|
||||
it 'falls back to extracting email from trace history when current input has no identity' do
|
||||
trace_input = [
|
||||
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
|
||||
{ role: 'assistant', content: 'Please confirm your email' },
|
||||
{ role: 'user', content: 'Yes' }
|
||||
].to_json
|
||||
|
||||
identity = tool.send(
|
||||
:resolve_contact_identity,
|
||||
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
|
||||
)
|
||||
|
||||
expect(identity).to eq({ email: 'Russel.winfield@example.com', phone_number: nil })
|
||||
end
|
||||
|
||||
it 'prioritizes explicit identity over contact and inferred identity' do
|
||||
state = {
|
||||
contact: { email: 'contact@example.com', phone_number: '+15550001111' },
|
||||
captain_v2_trace_current_input: 'my alternate is inferred@example.com'
|
||||
}
|
||||
overrides = { email: 'explicit@example.com', phone_number: '+15551234567' }
|
||||
|
||||
identity = tool.send(:resolve_contact_identity, state, overrides)
|
||||
|
||||
expect(identity).to eq({ email: 'explicit@example.com', phone_number: '+15551234567' })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,132 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::ShopifyGetOrdersTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:tool_context) { Struct.new(:state).new({ contact: { email: 'john@example.com', phone_number: '+15551234567' } }) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'formats successful order results' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).with(email: 'john@example.com', phone_number: '+15551234567', limit: 10).and_return(
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
orders: [
|
||||
{
|
||||
name: '#1001',
|
||||
created_at: '2026-01-10T10:00:00Z',
|
||||
currency: 'USD',
|
||||
total_price: '89.50',
|
||||
financial_status: 'paid',
|
||||
fulfillment_status: 'fulfilled',
|
||||
admin_url: 'https://store/admin/orders/91',
|
||||
line_items: [{ title: 'Red Sneakers', quantity: 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to include('Found 1 Shopify orders:')
|
||||
expect(result).to include('#1001')
|
||||
expect(result).to include('https://store/admin/orders/91')
|
||||
end
|
||||
|
||||
it 'returns missing identity message' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).and_return(
|
||||
{ ok: false, error: { code: :missing_identifier, message: 'missing' } }
|
||||
)
|
||||
|
||||
result = tool.perform(Struct.new(:state).new({ contact: {} }))
|
||||
|
||||
expect(result).to eq('I need the contact email or phone number to look up Shopify orders.')
|
||||
end
|
||||
|
||||
it 'falls back to extracting email from current input text when contact identity is missing' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
context = Struct.new(:state).new(
|
||||
{ contact: {}, captain_v2_trace_current_input: 'Where is my order Russel.winfield@example.com?' }
|
||||
)
|
||||
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
|
||||
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||
)
|
||||
|
||||
result = tool.perform(context)
|
||||
|
||||
expect(result).to eq('No orders found for the customer.')
|
||||
end
|
||||
|
||||
it 'uses explicit arguments over inferred state identity' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
context = Struct.new(:state).new(
|
||||
{ contact: {}, captain_v2_trace_current_input: 'Where is my order inferred@example.com?' }
|
||||
)
|
||||
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).with(email: 'explicit@example.com', phone_number: nil, limit: 10).and_return(
|
||||
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||
)
|
||||
|
||||
result = tool.perform(context, email: 'explicit@example.com')
|
||||
|
||||
expect(result).to eq('No orders found for the customer.')
|
||||
end
|
||||
|
||||
it 'uses trace history identity when current input is a plain confirmation' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
trace_input = [
|
||||
{ role: 'user', content: 'Where is my order Russel.winfield@example.com?' },
|
||||
{ role: 'assistant', content: 'Please confirm your email' },
|
||||
{ role: 'user', content: 'Yes' }
|
||||
].to_json
|
||||
context = Struct.new(:state).new(
|
||||
{ contact: {}, captain_v2_trace_current_input: 'Yes', captain_v2_trace_input: trace_input }
|
||||
)
|
||||
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).with(email: 'Russel.winfield@example.com', phone_number: nil, limit: 10).and_return(
|
||||
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||
)
|
||||
|
||||
result = tool.perform(context)
|
||||
|
||||
expect(result).to eq('No orders found for the customer.')
|
||||
end
|
||||
|
||||
it 'returns no result message from service' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).and_return(
|
||||
{ ok: false, error: { code: :no_results, message: 'No orders found for the customer.' } }
|
||||
)
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('No orders found for the customer.')
|
||||
end
|
||||
|
||||
it 'returns safe provider error message' do
|
||||
service = instance_double(Integrations::Shopify::OrdersService)
|
||||
allow(Integrations::Shopify::OrdersService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:orders_for_contact).and_raise(StandardError, 'boom')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('I could not fetch Shopify orders right now. Please try again shortly.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::ShopifySearchProductsTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'formats successful product search results' do
|
||||
service = instance_double(Integrations::Shopify::ProductsService)
|
||||
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:search_products).with(query: 'sneakers', limit: 10).and_return(
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
products: [
|
||||
{
|
||||
title: 'Red Sneakers',
|
||||
price: '120.00',
|
||||
availability: 'In stock',
|
||||
storefront_url: 'https://store/products/red-sneakers'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = tool.perform(nil, query: 'sneakers')
|
||||
|
||||
expect(result).to include('Found 1 matching Shopify products:')
|
||||
expect(result).to include('Red Sneakers')
|
||||
expect(result).to include('https://store/products/red-sneakers')
|
||||
end
|
||||
|
||||
it 'returns reconnect guidance for missing scope' do
|
||||
service = instance_double(Integrations::Shopify::ProductsService)
|
||||
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:search_products).and_return(
|
||||
{ ok: false, error: { code: :insufficient_scope, message: 'missing scope' } }
|
||||
)
|
||||
|
||||
result = tool.perform(nil, query: 'sneakers')
|
||||
|
||||
expect(result).to eq('Shopify integration requires additional permissions. Please reauthorize Shopify integration and try again.')
|
||||
end
|
||||
|
||||
it 'returns safe provider error message' do
|
||||
service = instance_double(Integrations::Shopify::ProductsService)
|
||||
allow(Integrations::Shopify::ProductsService).to receive(:new).with(account: account).and_return(service)
|
||||
allow(service).to receive(:search_products).and_raise(StandardError, 'boom')
|
||||
|
||||
result = tool.perform(nil, query: 'sneakers')
|
||||
|
||||
expect(result).to eq('I could not fetch Shopify products right now. Please try again shortly.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe '#available_agent_tools' do
|
||||
before do
|
||||
allow(described_class).to receive(:built_in_agent_tools).and_return([
|
||||
{ id: 'faq_lookup', title: 'FAQ' },
|
||||
{ id: 'shopify_search_products', title: 'Products' },
|
||||
{ id: 'shopify_get_orders', title: 'Orders' }
|
||||
])
|
||||
end
|
||||
|
||||
it 'hides shopify tools when account is not eligible for v2 shopify' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
|
||||
expect(assistant.available_agent_tools.pluck(:id)).to eq(['faq_lookup'])
|
||||
end
|
||||
|
||||
it 'shows shopify tools when v2 is enabled and shopify is connected' do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
|
||||
expect(assistant.available_agent_tools.pluck(:id)).to contain_exactly('faq_lookup', 'shopify_search_products', 'shopify_get_orders')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_tools' do
|
||||
let(:faq_tool_class) do
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
end
|
||||
|
||||
let(:handoff_tool_class) do
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
end
|
||||
|
||||
let(:search_tool_class) do
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
end
|
||||
|
||||
let(:orders_tool_class) do
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:resolve_tool_class).with('faq_lookup').and_return(faq_tool_class)
|
||||
allow(described_class).to receive(:resolve_tool_class).with('handoff').and_return(handoff_tool_class)
|
||||
allow(described_class).to receive(:resolve_tool_class).with('shopify_search_products').and_return(search_tool_class)
|
||||
allow(described_class).to receive(:resolve_tool_class).with('shopify_get_orders').and_return(orders_tool_class)
|
||||
end
|
||||
|
||||
it 'includes only default tools when account is ineligible' do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
|
||||
expect(assistant.send(:agent_tools).length).to eq(2)
|
||||
end
|
||||
|
||||
it 'includes shopify tools when account is eligible' do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
|
||||
expect(assistant.send(:agent_tools).length).to eq(4)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,138 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Shopify::OrdersService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:shopify_hook) do
|
||||
create(
|
||||
:integrations_hook,
|
||||
:shopify,
|
||||
account: account,
|
||||
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
|
||||
)
|
||||
end
|
||||
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
|
||||
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
|
||||
allow(client_service).to receive(:fetch_client).and_return(
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
client: shopify_client,
|
||||
hook: shopify_hook,
|
||||
scopes: %w[read_customers read_orders read_fulfillments read_products]
|
||||
}
|
||||
}
|
||||
)
|
||||
allow(client_service).to receive(:scopes_include?).with(%w[read_customers read_orders]).and_return(true)
|
||||
end
|
||||
|
||||
describe '#orders_for_contact' do
|
||||
let(:customer_response) do
|
||||
Struct.new(:body).new({ 'customers' => [{ 'id' => 'cust_1' }] })
|
||||
end
|
||||
|
||||
let(:orders_response) do
|
||||
Struct.new(:body).new(
|
||||
{
|
||||
'orders' => [
|
||||
{
|
||||
'id' => 91,
|
||||
'name' => '#1001',
|
||||
'created_at' => '2026-01-10T10:00:00Z',
|
||||
'total_price' => '89.50',
|
||||
'currency' => 'USD',
|
||||
'financial_status' => 'paid',
|
||||
'fulfillment_status' => 'fulfilled',
|
||||
'line_items' => [{ 'title' => 'Red Sneakers', 'quantity' => 1, 'price' => '89.50' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns normalized orders for email lookup' do
|
||||
allow(shopify_client).to receive(:get).with(
|
||||
path: 'customers/search.json',
|
||||
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
|
||||
).and_return(customer_response)
|
||||
allow(shopify_client).to receive(:get).with(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: 'cust_1',
|
||||
status: 'any',
|
||||
limit: 10,
|
||||
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||
}
|
||||
).and_return(orders_response)
|
||||
|
||||
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||
|
||||
expect(result[:ok]).to be true
|
||||
expect(result[:data][:orders]).to eq(
|
||||
[{
|
||||
id: 91,
|
||||
name: '#1001',
|
||||
created_at: '2026-01-10T10:00:00Z',
|
||||
total_price: '89.50',
|
||||
currency: 'USD',
|
||||
financial_status: 'paid',
|
||||
fulfillment_status: 'fulfilled',
|
||||
line_items: [{ title: 'Red Sneakers', quantity: 1, price: '89.50' }],
|
||||
admin_url: 'https://test-store.myshopify.com/admin/orders/91'
|
||||
}]
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns normalized orders for phone lookup' do
|
||||
allow(shopify_client).to receive(:get).with(
|
||||
path: 'customers/search.json',
|
||||
query: { query: 'phone:+15551234567', fields: 'id,email,phone' }
|
||||
).and_return(customer_response)
|
||||
allow(shopify_client).to receive(:get).with(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: 'cust_1',
|
||||
status: 'any',
|
||||
limit: 10,
|
||||
fields: 'id,name,created_at,total_price,currency,fulfillment_status,financial_status,line_items'
|
||||
}
|
||||
).and_return(orders_response)
|
||||
|
||||
result = service.orders_for_contact(email: nil, phone_number: '+15551234567')
|
||||
|
||||
expect(result[:ok]).to be true
|
||||
expect(result[:data][:orders].first[:id]).to eq(91)
|
||||
end
|
||||
|
||||
it 'returns missing_identifier when no identity is available' do
|
||||
result = service.orders_for_contact(email: nil, phone_number: nil)
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:missing_identifier)
|
||||
end
|
||||
|
||||
it 'returns no_results when customer is not found' do
|
||||
allow(shopify_client).to receive(:get).with(
|
||||
path: 'customers/search.json',
|
||||
query: { query: 'email:john@example.com', fields: 'id,email,phone' }
|
||||
).and_return(Struct.new(:body).new({ 'customers' => [] }))
|
||||
|
||||
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:no_results)
|
||||
end
|
||||
|
||||
it 'maps provider exceptions to provider_error' do
|
||||
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
|
||||
|
||||
result = service.orders_for_contact(email: 'john@example.com', phone_number: nil)
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:provider_error)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Shopify::ProductsService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:shopify_hook) do
|
||||
create(
|
||||
:integrations_hook,
|
||||
:shopify,
|
||||
account: account,
|
||||
settings: { scope: 'read_customers,read_orders,read_fulfillments,read_products' }
|
||||
)
|
||||
end
|
||||
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
|
||||
let(:client_service) { instance_double(Integrations::Shopify::ClientService) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Shopify::ClientService).to receive(:new).with(account: account).and_return(client_service)
|
||||
allow(client_service).to receive(:fetch_client).and_return(
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
client: shopify_client,
|
||||
hook: shopify_hook,
|
||||
scopes: %w[read_customers read_orders read_fulfillments read_products]
|
||||
}
|
||||
}
|
||||
)
|
||||
allow(client_service).to receive(:granted_scopes).and_return(%w[read_customers read_orders read_fulfillments])
|
||||
end
|
||||
|
||||
describe '#search_products' do
|
||||
it 'returns normalized product fields on success' do
|
||||
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||
allow(shopify_client).to receive(:get).and_return(
|
||||
Struct.new(:body).new(
|
||||
{
|
||||
'products' => [
|
||||
{
|
||||
'id' => 11,
|
||||
'title' => 'Red Sneakers',
|
||||
'vendor' => 'Acme',
|
||||
'product_type' => 'Shoes',
|
||||
'handle' => 'red-sneakers',
|
||||
'variants' => [{ 'price' => '129.99', 'available' => true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = service.search_products(query: 'sneakers')
|
||||
|
||||
expect(result[:ok]).to be true
|
||||
expect(result[:data][:products]).to eq(
|
||||
[{
|
||||
id: 11,
|
||||
title: 'Red Sneakers',
|
||||
vendor: 'Acme',
|
||||
product_type: 'Shoes',
|
||||
handle: 'red-sneakers',
|
||||
storefront_url: 'https://test-store.myshopify.com/products/red-sneakers',
|
||||
price: '129.99',
|
||||
availability: 'In stock'
|
||||
}]
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns no_results when products are empty' do
|
||||
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||
allow(shopify_client).to receive(:get).and_return(Struct.new(:body).new({ 'products' => [] }))
|
||||
|
||||
result = service.search_products(query: 'sneakers')
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:no_results)
|
||||
end
|
||||
|
||||
it 'falls back to keyword filtering when title search is empty' do
|
||||
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||
title_search_empty = Struct.new(:body).new({ 'products' => [] })
|
||||
active_products = Struct.new(:body).new(
|
||||
{
|
||||
'products' => [
|
||||
{
|
||||
'id' => 22,
|
||||
'title' => 'The Collection Snowboard: Liquid',
|
||||
'vendor' => 'Snow',
|
||||
'product_type' => 'Snowboard',
|
||||
'handle' => 'collection-snowboard-liquid',
|
||||
'variants' => [{ 'price' => '150.00', 'available' => true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
allow(shopify_client).to receive(:get).and_return(title_search_empty, active_products)
|
||||
|
||||
result = service.search_products(query: 'snowboard')
|
||||
|
||||
expect(result[:ok]).to be true
|
||||
expect(result[:data][:products].first[:title]).to eq('The Collection Snowboard: Liquid')
|
||||
end
|
||||
|
||||
it 'returns insufficient_scope when read_products is missing' do
|
||||
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(false)
|
||||
|
||||
result = service.search_products(query: 'sneakers')
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:insufficient_scope)
|
||||
end
|
||||
|
||||
it 'maps provider exceptions to provider_error' do
|
||||
allow(client_service).to receive(:scopes_include?).with('read_products').and_return(true)
|
||||
allow(shopify_client).to receive(:get).and_raise(StandardError, 'provider down')
|
||||
|
||||
result = service.search_products(query: 'sneakers')
|
||||
|
||||
expect(result[:ok]).to be false
|
||||
expect(result.dig(:error, :code)).to eq(:provider_error)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user