123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- require 'rails_helper'
- RSpec.describe Whatsapp::Webhook::Payload, :aggregate_failures, current_user_id: 1 do
- let(:channel) { create(:whatsapp_channel) }
- let(:from) do
- {
- phone: Faker::PhoneNumber.cell_phone_in_e164.delete('+'),
- name: Faker::Name.unique.name
- }
- end
- let(:user_data) do
- firstname, lastname = User.name_guess(from[:name])
- # Fallback to profile name if no firstname or lastname is found
- if firstname.blank? || lastname.blank?
- firstname, lastname = from[:name].split(%r{\s|\.|,|,\s}, 2)
- end
- {
- firstname: firstname&.strip,
- lastname: lastname&.strip,
- mobile: "+#{from[:phone]}",
- login: from[:phone],
- }
- end
- let(:event) { 'messages' }
- let(:type) { 'text' }
- let(:timestamp) { '1707921703' }
- let(:json) do
- {
- object: 'whatsapp_business_account',
- entry: [{
- id: '222259550976437',
- changes: [{
- value: {
- messaging_product: 'whatsapp',
- metadata: {
- display_phone_number: '15551340563',
- phone_number_id: channel.options[:phone_number_id]
- },
- contacts: [{
- profile: {
- name: from[:name]
- },
- wa_id: from[:phone]
- }],
- messages: [{
- from: from[:phone],
- id: 'wamid.HBgNNDkxNTE1NjA4MDY5OBUCABIYFjNFQjBDMUM4M0I5NDRFNThBMUQyMjYA',
- timestamp: timestamp,
- text: {
- body: 'Hello, world!'
- },
- type: type
- }]
- },
- field: event
- }]
- }]
- }.to_json
- end
- let(:uuid) { channel.options[:callback_url_uuid] }
- let(:signature) do
- OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), channel.options[:app_secret], json)
- end
- describe '.new' do
- context 'when channel not exists' do
- let(:uuid) { 0 }
- it 'raises NoChannelError' do
- expect { described_class.new(json:, uuid:, signature:) }.to raise_error(Whatsapp::Webhook::NoChannelError)
- end
- end
- context 'when signatures do not match' do
- let(:signature) { 'foobar' }
- it 'raises ValidationError' do
- expect { described_class.new(json:, uuid:, signature:) }.to raise_error(described_class::ValidationError)
- end
- end
- context 'when signatures match' do
- it 'does not raise any error' do
- expect { described_class.new(json:, uuid:, signature:) }.not_to raise_error
- end
- end
- end
- describe '.process' do
- context 'when event is not messages' do
- let(:event) { 'foobar' }
- it 'raises ProcessableError' do
- expect { described_class.new(json:, uuid:, signature:).process }.to raise_error(described_class::ProcessableError)
- end
- end
- context 'when message has errors' do
- let(:json) do
- {
- object: 'whatsapp_business_account',
- entry: [{
- id: '222259550976437',
- changes: [{
- value: {
- messaging_product: 'whatsapp',
- metadata: {
- display_phone_number: '15551340563',
- phone_number_id: channel.options[:phone_number_id]
- },
- contacts: [{
- profile: {
- name: from[:name]
- },
- wa_id: from[:phone]
- }],
- messages: [{
- from: from[:phone],
- id: 'wamid.HBgNNDkxNTE1NjA4MDY5OBUCABIYFjNFQjBDMUM4M0I5NDRFNThBMUQyMjYA',
- timestamp: '1707921703',
- text: {
- body: 'Hello, world!'
- },
- errors: [
- {
- code: 0,
- details: 'Unable to authenticate the app user',
- title: 'AuthException'
- }
- ],
- type: type
- }]
- },
- field: 'messages'
- }]
- }]
- }.to_json
- end
- it 'raises ProcessableError' do
- expect { described_class.new(json:, uuid:, signature:).process }.to raise_error(described_class::ProcessableError)
- end
- it 'logs the error' do
- allow(Rails.logger).to receive(:error)
- begin
- described_class.new(json:, uuid:, signature:).process
- rescue
- # noop
- end
- expect(Rails.logger).to have_received(:error)
- .with("WhatsApp channel (#{channel.options[:callback_url_uuid]}) - failed message: AuthException (0)")
- end
- it 'updates the channel status' do
- begin
- described_class.new(json:, uuid:, signature:).process
- rescue
- # noop
- end
- expect(channel.reload).to have_attributes(
- status_out: 'error',
- last_log_out: 'AuthException (0)',
- )
- end
- end
- context 'when an unsupported type is used' do
- let(:type) { 'foobar' }
- it 'raises ProcessableError' do
- expect { described_class.new(json:, uuid:, signature:).process }.to raise_error(described_class::ProcessableError)
- end
- end
- context 'when everything is fine' do
- it 'does not raise any error' do
- expect { described_class.new(json:, uuid:, signature:).process }.not_to raise_error
- end
- context 'when no user exists' do
- it 'creates user' do
- described_class.new(json:, uuid:, signature:).process
- expect(User.last).to have_attributes(user_data)
- end
- end
- context 'when user already exists' do
- context 'when mobile is in common format with +' do
- before { create(:user, user_data) }
- it 'does not create a new user' do
- expect { described_class.new(json:, uuid:, signature:).process }.not_to change(User, :count)
- end
- end
- context 'when mobile is in e164 format' do
- before { create(:user, user_data).tap { |u| u.update!(mobile: u.mobile.delete('+')) } }
- it 'does not create a new user' do
- expect { described_class.new(json:, uuid:, signature:).process }.not_to change(User, :count)
- end
- end
- end
- context 'when no ticket exists' do
- it 'creates ticket' do
- expect { described_class.new(json:, uuid:, signature:).process }.to change(Ticket, :count).by(1)
- end
- it 'sets ticket preferences' do
- described_class.new(json:, uuid:, signature:).process
- expect(Ticket.last.preferences).to include(
- channel_id: channel.id,
- channel_area: channel.area,
- whatsapp: {
- from: {
- phone_number: from[:phone],
- display_name: from[:name],
- },
- timestamp_incoming: '1707921703',
- },
- )
- end
- end
- context 'when ticket already exists' do
- let(:ticket_state) { 'open' }
- let(:timestamp) { '1707921803' }
- let(:setup) do
- user = create(:user, user_data)
- create(:authorization, user: user, uid: user.mobile, provider: 'whatsapp_business')
- create(:ticket, customer: user, group_id: channel.group_id, state_id: Ticket::State.find_by(name: ticket_state).id, preferences: { channel_id: channel.id, channel_area: channel.area, whatsapp: { from: { phone_number: from[:phone], display_name: from[:name] }, timestamp_incoming: '1707921703' } })
- end
- before { setup }
- context 'when ticket is open' do
- it 'does not create a new ticket' do
- expect { described_class.new(json:, uuid:, signature:).process }.not_to change(Ticket, :count)
- end
- it 'updates the ticket preferences' do
- described_class.new(json:, uuid:, signature:).process
- expect(Ticket.last.preferences).to include(
- channel_id: channel.id,
- channel_area: channel.area,
- whatsapp: {
- from: {
- phone_number: from[:phone],
- display_name: from[:name],
- },
- timestamp_incoming: '1707921803',
- },
- )
- end
- end
- context 'when ticket is closed' do
- let(:ticket_state) { 'closed' }
- it 'creates a new ticket' do
- expect { described_class.new(json:, uuid:, signature:).process }.to change(Ticket, :count).by(1)
- end
- end
- end
- end
- end
- describe '.process_status_message' do
- let(:channel) { create(:whatsapp_channel) }
- let(:from) do
- {
- phone: Faker::PhoneNumber.cell_phone_in_e164.delete('+'),
- name: Faker::Name.unique.name,
- }
- end
- let(:json) do
- {
- object: 'whatsapp_business_account',
- entry: [
- {
- id: '244742992051543',
- changes: [
- {
- value: {
- messaging_product: 'whatsapp',
- metadata: {
- display_phone_number: '15551340563',
- phone_number_id: channel.options[:phone_number_id],
- },
- statuses: [
- {
- id: message_id,
- status: 'failed',
- timestamp: '1708603746',
- recipient_id: '15551340563',
- errors: [
- {
- code: 131_047,
- title: 'Re-engagement message',
- message: 'Re-engagement message',
- error_data: {
- details: 'Message failed to send because more than 24 hours have passed since the customer last replied to this number.'
- },
- href: 'https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/'
- }
- ]
- }
- ]
- },
- field: 'messages'
- }
- ]
- }
- ]
- }.to_json
- end
- let(:article) { create(:whatsapp_article, :inbound, ticket: ticket) }
- let(:ticket) { create(:whatsapp_ticket, channel: channel) }
- let(:message_id) { article.message_id }
- let(:uuid) { channel.options[:callback_url_uuid] }
- let(:signature) do
- OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), channel.options[:app_secret], json)
- end
- context 'when all data is valid' do
- it 'creates a new record in the HttpLog' do
- described_class.new(json:, uuid:, signature:).process
- expect(HttpLog.last).to have_attributes(
- direction: 'in',
- facility: 'WhatsApp::Business',
- url: "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#{Rails.configuration.api_path}/channels_whatsapp_webhook/#{channel.options[:callback_url_uuid]}",
- status: '200',
- request: { content: JSON.parse(json).deep_symbolize_keys },
- response: { content: {} },
- method: 'POST',
- )
- end
- end
- end
- end
|