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:
@@ -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
|
||||
109
spec/controllers/shopify/callbacks_controller_spec.rb
Normal file
109
spec/controllers/shopify/callbacks_controller_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
95
spec/helpers/shopify/integration_helper_spec.rb
Normal file
95
spec/helpers/shopify/integration_helper_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user