feat(apps): Shopify Integration (#11101)

This PR adds native integration with Shopify. No more dashboard apps.
The support agents can view the orders, their status and the link to the
order page on the conversation sidebar.

This PR does the following: 
- Create an integration with Shopify (a new app is added in the
integrations tab)
- Option to configure it in SuperAdmin
- OAuth endpoint and the callbacks.
- Frontend component to render the orders. (We might need to cache it in
the future)
---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Pranav
2025-03-19 15:37:55 -07:00
committed by GitHub
parent a60dcda301
commit b34c526c51
35 changed files with 1211 additions and 37 deletions

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
# Stub class for ShopifyAPI response
class ShopifyAPIResponse
attr_reader :body
def initialize(body)
@body = body
end
end
RSpec.describe 'Shopify Integration API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
describe 'POST /api/v1/accounts/:account_id/integrations/shopify/auth' do
let(:shop_domain) { 'test-store.myshopify.com' }
context 'when it is an authenticated user' do
it 'returns a redirect URL for Shopify OAuth' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
params: { shop_domain: shop_domain },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('redirect_url')
expect(response.parsed_body['redirect_url']).to include(shop_domain)
end
it 'returns error when shop domain is missing' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Shop domain is required')
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/integrations/shopify/auth",
params: { shop_domain: shop_domain },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/accounts/:account_id/integrations/shopify/orders' do
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
# rubocop:disable RSpec/AnyInstance
let(:shopify_client) { instance_double(ShopifyAPI::Clients::Rest::Admin) }
let(:customers_response) do
instance_double(
ShopifyAPIResponse,
body: { 'customers' => [{ 'id' => '123' }] }
)
end
let(:orders_response) do
instance_double(
ShopifyAPIResponse,
body: {
'orders' => [{
'id' => '456',
'email' => 'test@example.com',
'created_at' => Time.now.iso8601,
'total_price' => '100.00',
'currency' => 'USD',
'fulfillment_status' => 'fulfilled',
'financial_status' => 'paid'
}]
}
)
end
before do
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:shopify_client).and_return(shopify_client)
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_id).and_return('test_client_id')
allow_any_instance_of(Api::V1::Accounts::Integrations::ShopifyController).to receive(:client_secret).and_return('test_client_secret')
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
).and_return(customers_response)
allow(shopify_client).to receive(:get).with(
path: 'orders.json',
query: { customer_id: '123', status: 'any', fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' }
).and_return(orders_response)
end
it 'returns orders for the contact' do
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('orders')
expect(response.parsed_body['orders'].length).to eq(1)
expect(response.parsed_body['orders'][0]['id']).to eq('456')
end
it 'returns error when contact has no email or phone' do
contact_without_info = create(:contact, account: account)
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact_without_info.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Contact information missing')
end
it 'returns empty array when no customers found' do
empty_customers_response = instance_double(
ShopifyAPIResponse,
body: { 'customers' => [] }
)
allow(shopify_client).to receive(:get).with(
path: 'customers/search.json',
query: { query: "email:#{contact.email} OR phone:#{contact.phone_number}", fields: 'id,email,phone' }
).and_return(empty_customers_response)
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['orders']).to eq([])
end
# rubocop:enable RSpec/AnyInstance
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/integrations/shopify/orders",
params: { contact_id: contact.id },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
it 'deletes the shopify integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
as: :json
end.to change { account.hooks.count }.by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View File

@@ -0,0 +1,109 @@
require 'rails_helper'
RSpec.describe Shopify::CallbacksController, type: :request do
let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) }
let(:state) { SecureRandom.hex(10) }
let(:shop) { 'my-store.myshopify.com' }
let(:frontend_url) { 'http://www.example.com' }
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
let(:oauth_client) { instance_double(OAuth2::Client) }
let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) }
describe 'GET /shopify/callback' do
let(:access_token) { SecureRandom.hex(10) }
let(:response_body) do
{
'access_token' => access_token,
'scope' => 'read_products,write_products'
}
end
before do
stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => frontend_url))
end
context 'when successful' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(described_class).to receive(:new).and_return(controller)
stub_request(:post, "https://#{shop}/admin/oauth/access_token")
.to_return(
status: 200,
body: response_body.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates a new integration hook' do
expect do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
end.to change(Integrations::Hook, :count).by(1)
hook = Integrations::Hook.last
expect(hook.access_token).to eq(access_token)
expect(hook.app_id).to eq('shopify')
expect(hook.status).to eq('enabled')
expect(hook.reference_id).to eq(shop)
expect(hook.settings).to eq(
'scope' => 'read_products,write_products'
)
expect(response).to redirect_to(shopify_redirect_uri)
end
end
context 'when the code is missing' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(controller).to receive(:oauth_client).and_return(oauth_client)
allow(oauth_client).to receive(:auth_code).and_raise(StandardError)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the shopify_redirect_uri with error' do
get shopify_callback_path, params: { state: state, shop: shop }
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
end
end
context 'when the token is invalid' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
allow(controller).to receive(:oauth_client).and_return(oauth_client)
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
allow(auth_code_strategy).to receive(:get_token).and_raise(
OAuth2::Error.new(
OpenStruct.new(
parsed: { 'error' => 'invalid_grant' },
status: 400
)
)
)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the shopify_redirect_uri with error' do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
expect(response).to redirect_to("#{shopify_redirect_uri}?error=true")
end
end
context 'when state parameter is invalid' do
before do
controller = described_class.new
allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
allow(controller).to receive(:account).and_return(nil)
allow(described_class).to receive(:new).and_return(controller)
end
it 'redirects to the frontend URL with error' do
get shopify_callback_path, params: { code: code, state: state, shop: shop }
expect(response).to redirect_to("#{frontend_url}?error=true")
end
end
end
end

View File

@@ -31,5 +31,11 @@ FactoryBot.define do
app_id { 'linear' }
access_token { SecureRandom.hex }
end
trait :shopify do
app_id { 'shopify' }
access_token { SecureRandom.hex }
reference_id { 'test-store.myshopify.com' }
end
end
end

View File

@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe Shopify::IntegrationHelper do
include described_class
describe '#generate_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_shopify_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_shopify_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Shopify token: Test error')
expect(generate_shopify_token(account_id)).to be_nil
end
end
end
describe '#verify_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_shopify_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_shopify_token('')).to be_nil
expect(verify_shopify_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Shopify token:/)
expect(verify_shopify_token('invalid_token')).to be_nil
end
end
end
describe '#client_id' do
it 'loads client_id from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil)
client_id
end
end
describe '#client_secret' do
it 'loads client_secret from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil)
client_secret
end
end
end

View File

@@ -51,17 +51,38 @@ RSpec.describe Integrations::App do
end
end
context 'when the app is linear' do
let(:app_name) { 'linear' }
context 'when the app is shopify' do
let(:app_name) { 'shopify' }
it 'returns true if the linear integration feature is disabled' do
it 'returns true if the shopify integration feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
it 'returns false if the shopify integration feature is disabled' do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be false
end
it 'returns false if the linear integration feature is enabled' do
it 'returns false if SHOPIFY_CLIENT_ID is not present, even if feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return(nil)
expect(app.active?(account)).to be false
end
end
context 'when the app is linear' do
let(:app_name) { 'linear' }
it 'returns false if the linear integration feature is disabled' do
expect(app.active?(account)).to be false
end
it 'returns true if the linear integration feature is enabled' do
account.enable_features('linear_integration')
account.save!
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
end