Resolves 26 conflicts via manual review. Key decisions: - signature: kept fork's send-time architecture (PR #79), discarded upstream's editor-manipulation functions - WhatsApp incoming: combined fork's two-layer locking (source_id + contact phone) with upstream's blocked-contact drop. Fixed pre-existing regression where echoes were silently dropped - InstallationConfig: upstream's simplified coder (validated against legacy YAML-in-jsonb data) - schema.rb: regenerated, stripped kanban tables from other branches, restored f_unaccent SQL function
377 lines
16 KiB
Ruby
377 lines
16 KiB
Ruby
require 'rails_helper'
|
|
|
|
describe Integrations::Dialogflow::ProcessorService do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:hook) { create(:integrations_hook, :dialogflow, inbox: inbox, account: account) }
|
|
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
|
let(:message) { create(:message, account: account, conversation: conversation) }
|
|
let(:template_message) { create(:message, account: account, conversation: conversation, message_type: :template, content: 'Bot message') }
|
|
let(:event_name) { 'message.created' }
|
|
let(:event_data) { { message: message } }
|
|
let(:dialogflow_text_double) { double }
|
|
|
|
describe '#perform' do
|
|
let(:dialogflow_service) { double }
|
|
let(:dialogflow_response) do
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
fulfillment_messages: [
|
|
{ text: dialogflow_text_double }
|
|
]
|
|
)
|
|
end
|
|
|
|
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
|
|
|
before do
|
|
allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response)
|
|
allow(processor).to receive(:get_response).and_return(dialogflow_service)
|
|
allow(dialogflow_text_double).to receive(:to_h).and_return({ text: ['hello payload'] })
|
|
end
|
|
|
|
context 'when valid message and dialogflow returns fullfillment text' do
|
|
it 'creates the response message' do
|
|
processor.perform
|
|
expect(conversation.reload.messages.last.content).to eql('hello payload')
|
|
end
|
|
end
|
|
|
|
context 'when invalid message and dialogflow returns empty block' do
|
|
it 'will not create the response message' do
|
|
event_data = { message: template_message }
|
|
processor = described_class.new(event_name: event_name, hook: hook, event_data: event_data)
|
|
processor.perform
|
|
expect(conversation.reload.messages.last.content).not_to eql('hello payload')
|
|
end
|
|
end
|
|
|
|
context 'when dilogflow raises exception' do
|
|
it 'tracks hook into exception tracked' do
|
|
last_message = conversation.reload.messages.last.content
|
|
allow(dialogflow_service).to receive(:query_result).and_raise(StandardError)
|
|
processor.perform
|
|
expect(conversation.reload.messages.last.content).to eql(last_message)
|
|
end
|
|
end
|
|
|
|
context 'when dilogflow settings are not present' do
|
|
it 'will get empty response' do
|
|
last_count = conversation.reload.messages.count
|
|
allow(processor).to receive(:get_response).and_return({})
|
|
hook.settings = { 'project_id' => 'something_invalid', 'credentials' => {} }
|
|
hook.save!
|
|
processor.perform
|
|
|
|
expect(conversation.reload.messages.count).to eql(last_count)
|
|
end
|
|
end
|
|
|
|
context 'when dialogflow returns fullfillment text to be empty' do
|
|
let(:dialogflow_response) do
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
fulfillment_messages: [{ payload: { content: 'hello payload random' } }]
|
|
)
|
|
end
|
|
|
|
it 'creates the response message based on fulfillment messages' do
|
|
processor.perform
|
|
expect(conversation.reload.messages.last.content).to eql('hello payload random')
|
|
end
|
|
end
|
|
|
|
context 'when dialogflow returns action' do
|
|
let(:dialogflow_response) do
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
fulfillment_messages: [{ payload: { action: 'handoff' } }]
|
|
)
|
|
end
|
|
|
|
it 'handsoff the conversation to agent' do
|
|
processor.perform
|
|
expect(conversation.status).to eql('open')
|
|
end
|
|
end
|
|
|
|
context 'when dialogflow returns action and messages if available' do
|
|
let(:dialogflow_response) do
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
fulfillment_messages: [{ payload: { action: 'handoff' } }, { text: dialogflow_text_double }]
|
|
)
|
|
end
|
|
|
|
it 'handsoff the conversation to agent' do
|
|
processor.perform
|
|
expect(conversation.reload.status).to eql('open')
|
|
expect(conversation.messages.last.content).to eql('hello payload')
|
|
end
|
|
end
|
|
|
|
context 'when dialogflow returns resolve action' do
|
|
let(:dialogflow_response) do
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
fulfillment_messages: [{ payload: { action: 'resolve' } }, { text: dialogflow_text_double }]
|
|
)
|
|
end
|
|
|
|
it 'resolves the conversation without moving it to an agent' do
|
|
processor.perform
|
|
expect(conversation.reload.status).to eql('resolved')
|
|
expect(conversation.messages.last.content).to eql('hello payload')
|
|
end
|
|
end
|
|
|
|
context 'when conversation is not bot' do
|
|
let(:conversation) { create(:conversation, account: account, status: :open) }
|
|
|
|
it 'returns nil' do
|
|
expect(processor.perform).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when message is private' do
|
|
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
|
|
|
it 'returns nil' do
|
|
expect(processor.perform).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when message updated' do
|
|
let(:message) do
|
|
create(:message, account: account, conversation: conversation, private: true,
|
|
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
|
end
|
|
let(:event_name) { 'message.updated' }
|
|
|
|
it 'returns submitted value for message content' do
|
|
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#get_response' do
|
|
let(:google_dialogflow) { Google::Cloud::Dialogflow::V2::Sessions::Client }
|
|
let(:session_client) { double }
|
|
let(:session) { double }
|
|
let(:query_input) { { text: { text: message, language_code: 'en-US' } } }
|
|
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
|
|
|
before do
|
|
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds' }) # rubocop:disable Rails/SaveBang
|
|
allow(google_dialogflow).to receive(:new).and_return(session_client)
|
|
allow(session_client).to receive(:detect_intent).and_return({ session: session, query_input: query_input })
|
|
end
|
|
|
|
it 'returns intended response' do
|
|
response = processor.send(:get_response, conversation.contact_inbox.source_id, message.content)
|
|
expect(response[:query_input][:text][:text]).to eq(message)
|
|
expect(response[:query_input][:text][:language_code]).to eq('en-US')
|
|
end
|
|
|
|
it 'disables the hook if permission errors are thrown' do
|
|
allow(session_client).to receive(:detect_intent).and_raise(Google::Cloud::PermissionDeniedError)
|
|
|
|
expect { processor.send(:get_response, conversation.contact_inbox.source_id, message.content) }
|
|
.to change(hook, :status).from('enabled').to('disabled')
|
|
end
|
|
end
|
|
|
|
describe 'region configuration' do
|
|
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
|
|
|
context 'when region is global or not specified' do
|
|
it 'uses global endpoint and session path' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
|
|
|
expect(processor.send(:dialogflow_endpoint)).to eq('dialogflow.googleapis.com')
|
|
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/agent/sessions/test-session')
|
|
end
|
|
end
|
|
|
|
context 'when region is specified' do
|
|
it 'uses regional endpoint and session path' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
|
|
|
expect(processor.send(:dialogflow_endpoint)).to eq('europe-west1-dialogflow.googleapis.com')
|
|
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/locations/europe-west1/agent/sessions/test-session')
|
|
end
|
|
end
|
|
|
|
it 'configures client with correct endpoint' do
|
|
hook.update!(settings: { 'project_id' => 'test', 'credentials' => {}, 'region' => 'europe-west1' })
|
|
config = OpenStruct.new
|
|
expect(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:configure).and_yield(config)
|
|
|
|
processor.send(:configure_dialogflow_client_defaults)
|
|
expect(config.endpoint).to eq('europe-west1-dialogflow.googleapis.com')
|
|
end
|
|
|
|
context 'when calling detect_intent' do
|
|
let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
|
|
|
|
before do
|
|
allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
|
|
end
|
|
|
|
it 'uses global session path when region is not specified' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
|
|
|
expect(mock_client).to receive(:detect_intent).with(
|
|
session: 'projects/test-project/agent/sessions/test-session',
|
|
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
|
)
|
|
|
|
processor.send(:detect_intent, 'test-session', 'Hello')
|
|
end
|
|
|
|
it 'uses regional session path when region is specified' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
|
|
|
expect(mock_client).to receive(:detect_intent).with(
|
|
session: 'projects/test-project/locations/europe-west1/agent/sessions/test-session',
|
|
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
|
)
|
|
|
|
processor.send(:detect_intent, 'test-session', 'Hello')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'language_code configuration' do
|
|
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
|
let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
|
|
|
|
before do
|
|
allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
|
|
end
|
|
|
|
context 'when language_code is configured' do
|
|
it 'uses the configured language code' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'es-ES' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('es-ES')
|
|
end
|
|
|
|
it 'passes the configured language code to detect_intent' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'fr-FR' })
|
|
|
|
expect(mock_client).to receive(:detect_intent).with(
|
|
session: 'projects/test-project/agent/sessions/test-session',
|
|
query_input: { text: { text: 'Hello', language_code: 'fr-FR' } }
|
|
)
|
|
|
|
processor.send(:detect_intent, 'test-session', 'Hello')
|
|
end
|
|
end
|
|
|
|
context 'when language_code is set to auto' do
|
|
it 'uses contact language_code from additional_attributes' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'pt-BR' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('pt-BR')
|
|
end
|
|
|
|
it 'normalizes short contact language codes to supported Dialogflow locales' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'en' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
|
|
it 'maps spanish short codes to the preferred locale' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'es' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('es-ES')
|
|
end
|
|
|
|
it 'maps unsupported spanish regional variants through the base language' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'es-MX' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('es-ES')
|
|
end
|
|
|
|
it 'maps portuguese short codes to the preferred locale' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'pt' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('pt-BR')
|
|
end
|
|
|
|
it 'maps unsupported portuguese regional variants through the base language' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'pt-AO' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('pt-BR')
|
|
end
|
|
|
|
it 'maps chinese short codes to the preferred locale' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'zh' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('zh-CN')
|
|
end
|
|
|
|
it 'maps arabic short codes to the preferred locale' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'ar' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('ar')
|
|
end
|
|
|
|
it 'normalizes contact language formatting before checking supported locales' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'pt_br' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('pt-BR')
|
|
end
|
|
|
|
it 'falls back to en-US for unsupported contact language codes' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: { 'language_code' => 'xx' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
|
|
it 'falls back to en-US when contact has no language' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => 'auto' })
|
|
conversation.contact.update!(additional_attributes: {})
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
end
|
|
|
|
context 'when language_code is not configured' do
|
|
before do
|
|
conversation.contact.update(additional_attributes: { 'language_code' => 'pt-BR' })
|
|
end
|
|
|
|
it 'falls back to en-US' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
end
|
|
|
|
context 'when language_code is empty or blank' do
|
|
before do
|
|
conversation.contact.update(additional_attributes: { 'language_code' => 'pt-BR' })
|
|
end
|
|
|
|
it 'falls back to en-US for empty string' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => '' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
|
|
it 'falls back to en-US for whitespace-only string' do
|
|
hook.update!(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'language_code' => ' ' })
|
|
|
|
expect(processor.send(:dialogflow_language_code)).to eq('en-US')
|
|
end
|
|
end
|
|
end
|
|
end
|