feat: add notion integration app
This commit is contained in:
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'dialogflow'
|
||||
end
|
||||
|
||||
def notion?
|
||||
app_id == 'notion'
|
||||
end
|
||||
|
||||
def disable
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
@@ -63,6 +63,13 @@ linear:
|
||||
action: https://linear.app/oauth/authorize
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
notion:
|
||||
id: notion
|
||||
logo: notion.png
|
||||
i18n_key: notion
|
||||
action: https://api.notion.com/v1/oauth/authorize
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
slack:
|
||||
id: slack
|
||||
logo: slack.png
|
||||
|
||||
@@ -255,6 +255,10 @@ en:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
short_description: 'Integrate databases, documents and pages directly with Captain.'
|
||||
description: 'Connect your Notion workspace to allow captain to generate responses from your databases, documents and pages.'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
short_description: 'Access order details and customer data from your Shopify store.'
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Notion Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
# Validate URL components
|
||||
url = response.parsed_body['url']
|
||||
uri = URI.parse(url)
|
||||
params = CGI.parse(uri.query)
|
||||
|
||||
expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(params['response_type']).to eq(['code'])
|
||||
expect(params['owner']).to eq(['user'])
|
||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
|
||||
|
||||
# Validate state parameter exists and can be decoded back to the account
|
||||
expect(params['state']).to be_present
|
||||
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
|
||||
expect(decoded_account).to eq(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotionConcern, type: :concern do
|
||||
let(:controller_class) do
|
||||
Class.new do
|
||||
include NotionConcern
|
||||
end
|
||||
end
|
||||
|
||||
let(:controller) { controller_class.new }
|
||||
|
||||
describe '#notion_client' do
|
||||
let(:client_id) { 'test_notion_client_id' }
|
||||
let(:client_secret) { 'test_notion_client_secret' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
|
||||
end
|
||||
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
expect(OAuth2::Client).to receive(:new).with(
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
site: 'https://api.notion.com',
|
||||
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
|
||||
token_url: 'https://api.notion.com/v1/oauth/token',
|
||||
auth_scheme: :basic_auth
|
||||
}
|
||||
)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'loads client credentials from GlobalConfigService' do
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'returns OAuth2::Client instance' do
|
||||
client = controller.notion_client
|
||||
expect(client).to be_an_instance_of(OAuth2::Client)
|
||||
end
|
||||
|
||||
it 'configures client with Notion-specific endpoints' do
|
||||
client = controller.notion_client
|
||||
expect(client.site).to eq('https://api.notion.com')
|
||||
expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
|
||||
expect(client.options[:auth_scheme]).to eq(:basic_auth)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#scope' do
|
||||
it 'returns read scope for Notion API' do
|
||||
expect(controller.send(:scope)).to eq('read')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notion::CallbacksController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:state) { account.to_sgid.to_s }
|
||||
let(:oauth_code) { 'test_oauth_code' }
|
||||
|
||||
let(:mock_oauth_client) { instance_double(OAuth2::Client) }
|
||||
let(:mock_auth_code) { instance_double(OAuth2::Strategy::AuthCode) }
|
||||
let(:mock_token_response) { instance_double(OAuth2::AccessToken) }
|
||||
let(:mock_response) { instance_double(Faraday::Response) }
|
||||
|
||||
let(:notion_response_body) do
|
||||
{
|
||||
'access_token' => 'notion_access_token_123',
|
||||
'token_type' => 'bearer',
|
||||
'workspace_name' => 'Test Workspace',
|
||||
'workspace_id' => 'workspace_123',
|
||||
'workspace_icon' => 'https://notion.so/icon.png',
|
||||
'bot_id' => 'bot_123',
|
||||
'owner' => {
|
||||
'type' => 'user',
|
||||
'user' => {
|
||||
'id' => 'user_123',
|
||||
'name' => 'Test User'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:notion_client).and_return(mock_oauth_client)
|
||||
allow(mock_oauth_client).to receive(:auth_code).and_return(mock_auth_code)
|
||||
allow(mock_auth_code).to receive(:get_token).and_return(mock_token_response)
|
||||
allow(mock_token_response).to receive(:response).and_return(mock_response)
|
||||
allow(mock_response).to receive(:parsed).and_return(notion_response_body)
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
context 'when OAuth callback is successful' do
|
||||
it 'creates a new integration hook' do
|
||||
expect do
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
end.to change(Integrations::Hook, :count).by(1)
|
||||
end
|
||||
|
||||
it 'sets correct hook attributes' do
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.account).to eq(account)
|
||||
expect(hook.app_id).to eq('notion')
|
||||
expect(hook.access_token).to eq('notion_access_token_123')
|
||||
expect(hook.status).to eq('enabled')
|
||||
end
|
||||
|
||||
it 'stores notion workspace data in settings' do
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.settings['token_type']).to eq('bearer')
|
||||
expect(hook.settings['workspace_name']).to eq('Test Workspace')
|
||||
expect(hook.settings['workspace_id']).to eq('workspace_123')
|
||||
expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
|
||||
expect(hook.settings['bot_id']).to eq('bot_123')
|
||||
expect(hook.settings['owner']).to eq(notion_response_body['owner'])
|
||||
end
|
||||
|
||||
it 'redirects to integration settings page' do
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
|
||||
expect(response).to redirect_to("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion")
|
||||
end
|
||||
|
||||
it 'calls the OAuth client with correct parameters' do
|
||||
expect(mock_auth_code).to receive(:get_token).with(
|
||||
oauth_code,
|
||||
redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"
|
||||
)
|
||||
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hook save fails during handle_response' do
|
||||
it 'raises error and uses parent exception handling' do
|
||||
mock_hook = instance_double(Integrations::Hook)
|
||||
allow(account.hooks).to receive(:new).and_return(mock_hook)
|
||||
allow(mock_hook).to receive(:save!).and_raise(StandardError, 'Save failed')
|
||||
|
||||
# Parent class handles exceptions with ChatwootExceptionTracker and redirects to '/'
|
||||
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
get :show, params: { code: oauth_code, state: state }
|
||||
|
||||
expect(response).to redirect_to('/')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provider-specific methods' do
|
||||
describe '#provider_name' do
|
||||
it 'returns notion' do
|
||||
expect(controller.send(:provider_name)).to eq('notion')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#oauth_client' do
|
||||
it 'returns notion_client' do
|
||||
expect(controller.send(:oauth_client)).to eq(mock_oauth_client)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#notion_redirect_uri' do
|
||||
it 'returns correct redirect URI' do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
expected_uri = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
expect(controller.send(:notion_redirect_uri)).to eq(expected_uri)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -78,6 +78,46 @@ RSpec.describe Integrations::Hook do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'app type methods' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#slack?' do
|
||||
it 'returns true for slack integration' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'slack')
|
||||
expect(hook.slack?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-slack integrations' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'notion')
|
||||
expect(hook.slack?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#dialogflow?' do
|
||||
it 'returns true for dialogflow integration' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'dialogflow')
|
||||
expect(hook.dialogflow?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-dialogflow integrations' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'notion')
|
||||
expect(hook.dialogflow?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#notion?' do
|
||||
it 'returns true for notion integration' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'notion')
|
||||
expect(hook.notion?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-notion integrations' do
|
||||
hook = create(:integrations_hook, account: account, app_id: 'slack')
|
||||
expect(hook.notion?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#crm_integration?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user