mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: Added the backend support for twilio content templates (#12272)
Added comprehensive Twilio WhatsApp content template support (Phase 1)
enabling text, media, and quick reply templates with proper parameter
conversion, sync capabilities.
 **Template Types Supported**
  - Basic Text Templates: Simple text with variables ({{1}}, {{2}})
  - Media Templates: Image/Video/Document templates with text variables
  - Quick Reply Templates: Interactive button templates
  
 Front end changes is available via #12277
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
			
			
This commit is contained in:
		| @@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def sync_templates |   def sync_templates | ||||||
|     unless @inbox.channel.is_a?(Channel::Whatsapp) |     return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? | ||||||
|       return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } |  | ||||||
|     end |  | ||||||
|  |  | ||||||
|     Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) |     trigger_template_sync | ||||||
|     render status: :ok, json: { message: 'Template sync initiated successfully' } |     render status: :ok, json: { message: 'Template sync initiated successfully' } | ||||||
|   rescue StandardError => e |   rescue StandardError => e | ||||||
|     render status: :internal_server_error, json: { error: e.message } |     render status: :internal_server_error, json: { error: e.message } | ||||||
| @@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | |||||||
|       [] |       [] | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def whatsapp_channel? | ||||||
|  |     @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def trigger_template_sync | ||||||
|  |     if @inbox.whatsapp? | ||||||
|  |       Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) | ||||||
|  |     elsif @inbox.twilio? && @inbox.channel.whatsapp? | ||||||
|  |       Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
| Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') | Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								app/jobs/channels/twilio/templates_sync_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/jobs/channels/twilio/templates_sync_job.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | class Channels::Twilio::TemplatesSyncJob < ApplicationJob | ||||||
|  |   queue_as :low | ||||||
|  |  | ||||||
|  |   def perform(twilio_channel) | ||||||
|  |     Twilio::TemplateSyncService.new(channel: twilio_channel).call | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -2,16 +2,18 @@ | |||||||
| # | # | ||||||
| # Table name: channel_twilio_sms | # Table name: channel_twilio_sms | ||||||
| # | # | ||||||
| #  id                    :bigint           not null, primary key | #  id                             :bigint           not null, primary key | ||||||
| #  account_sid           :string           not null | #  account_sid                    :string           not null | ||||||
| #  api_key_sid           :string | #  api_key_sid                    :string | ||||||
| #  auth_token            :string           not null | #  auth_token                     :string           not null | ||||||
| #  medium                :integer          default("sms") | #  content_templates              :jsonb | ||||||
| #  messaging_service_sid :string | #  content_templates_last_updated :datetime | ||||||
| #  phone_number          :string | #  medium                         :integer          default("sms") | ||||||
| #  created_at            :datetime         not null | #  messaging_service_sid          :string | ||||||
| #  updated_at            :datetime         not null | #  phone_number                   :string | ||||||
| #  account_id            :integer          not null | #  created_at                     :datetime         not null | ||||||
|  | #  updated_at                     :datetime         not null | ||||||
|  | #  account_id                     :integer          not null | ||||||
| # | # | ||||||
| # Indexes | # Indexes | ||||||
| # | # | ||||||
|   | |||||||
| @@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService | |||||||
|  |  | ||||||
|   def perform_reply |   def perform_reply | ||||||
|     begin |     begin | ||||||
|       twilio_message = channel.send_message(**message_params) |       twilio_message = if template_params.present? | ||||||
|  |                          send_template_message | ||||||
|  |                        else | ||||||
|  |                          channel.send_message(**message_params) | ||||||
|  |                        end | ||||||
|     rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e |     rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e | ||||||
|       Messages::StatusUpdateService.new(message, 'failed', e.message).perform |       Messages::StatusUpdateService.new(message, 'failed', e.message).perform | ||||||
|     end |     end | ||||||
|     message.update!(source_id: twilio_message.sid) if twilio_message |     message.update!(source_id: twilio_message.sid) if twilio_message | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def send_template_message | ||||||
|  |     content_sid, content_variables = process_template_params | ||||||
|  |  | ||||||
|  |     if content_sid.blank? | ||||||
|  |       message.update!(status: :failed, external_error: 'Template not found') | ||||||
|  |       return nil | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     send_params = { | ||||||
|  |       to: contact_inbox.source_id, | ||||||
|  |       content_sid: content_sid | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     send_params[:content_variables] = content_variables.to_json if content_variables.present? | ||||||
|  |     send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true) | ||||||
|  |  | ||||||
|  |     # Add messaging service or from number | ||||||
|  |     send_params = send_params.merge(channel.send(:send_message_from)) | ||||||
|  |  | ||||||
|  |     channel.send(:client).messages.create(**send_params) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def template_params | ||||||
|  |     message.additional_attributes && message.additional_attributes['template_params'] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_template_params | ||||||
|  |     return [nil, nil] if template_params.blank? | ||||||
|  |  | ||||||
|  |     Twilio::TemplateProcessorService.new( | ||||||
|  |       channel: channel, | ||||||
|  |       template_params: template_params, | ||||||
|  |       message: message | ||||||
|  |     ).call | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def message_params |   def message_params | ||||||
|     { |     { | ||||||
|       body: message.outgoing_content, |       body: message.outgoing_content, | ||||||
|   | |||||||
							
								
								
									
										121
									
								
								app/services/twilio/template_processor_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								app/services/twilio/template_processor_service.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | class Twilio::TemplateProcessorService | ||||||
|  |   pattr_initialize [:channel!, :template_params, :message] | ||||||
|  |  | ||||||
|  |   def call | ||||||
|  |     return [nil, nil] if template_params.blank? | ||||||
|  |  | ||||||
|  |     template = find_template | ||||||
|  |     return [nil, nil] if template.blank? | ||||||
|  |  | ||||||
|  |     content_variables = build_content_variables(template) | ||||||
|  |     [template['content_sid'], content_variables] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def find_template | ||||||
|  |     channel.content_templates&.dig('templates')&.find do |template| | ||||||
|  |       template['friendly_name'] == template_params['name'] && | ||||||
|  |         template['language'] == (template_params['language'] || 'en') && | ||||||
|  |         template['status'] == 'approved' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def build_content_variables(template) | ||||||
|  |     case template['template_type'] | ||||||
|  |     when 'text', 'quick_reply' | ||||||
|  |       convert_text_template(template_params) # Text and quick reply templates use body variables | ||||||
|  |     when 'media' | ||||||
|  |       convert_media_template(template_params) | ||||||
|  |     else | ||||||
|  |       {} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def convert_text_template(chatwoot_params) | ||||||
|  |     return process_key_value_params(chatwoot_params['processed_params']) if chatwoot_params['processed_params'].present? | ||||||
|  |  | ||||||
|  |     process_whatsapp_format_params(chatwoot_params['parameters']) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_key_value_params(processed_params) | ||||||
|  |     content_variables = {} | ||||||
|  |     processed_params.each do |key, value| | ||||||
|  |       content_variables[key.to_s] = value.to_s | ||||||
|  |     end | ||||||
|  |     content_variables | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_whatsapp_format_params(parameters) | ||||||
|  |     content_variables = {} | ||||||
|  |     parameter_index = 1 | ||||||
|  |  | ||||||
|  |     parameters&.each do |component| | ||||||
|  |       next unless component['type'] == 'body' | ||||||
|  |  | ||||||
|  |       component['parameters']&.each do |param| | ||||||
|  |         content_variables[parameter_index.to_s] = param['text'] | ||||||
|  |         parameter_index += 1 | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     content_variables | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def convert_media_template(chatwoot_params) | ||||||
|  |     content_variables = {} | ||||||
|  |  | ||||||
|  |     # Handle processed_params format (key-value pairs) | ||||||
|  |     if chatwoot_params['processed_params'].present? | ||||||
|  |       chatwoot_params['processed_params'].each do |key, value| | ||||||
|  |         content_variables[key.to_s] = value.to_s | ||||||
|  |       end | ||||||
|  |     else | ||||||
|  |       # Handle parameters format (WhatsApp Cloud API format) | ||||||
|  |       parameter_index = 1 | ||||||
|  |       chatwoot_params['parameters']&.each do |component| | ||||||
|  |         parameter_index = process_component(component, content_variables, parameter_index) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     content_variables | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_component(component, content_variables, parameter_index) | ||||||
|  |     case component['type'] | ||||||
|  |     when 'header' | ||||||
|  |       process_media_header(component, content_variables, parameter_index) | ||||||
|  |     when 'body' | ||||||
|  |       process_body_parameters(component, content_variables, parameter_index) | ||||||
|  |     else | ||||||
|  |       parameter_index | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_media_header(component, content_variables, parameter_index) | ||||||
|  |     media_param = component['parameters']&.first | ||||||
|  |     return parameter_index unless media_param | ||||||
|  |  | ||||||
|  |     media_link = extract_media_link(media_param) | ||||||
|  |     if media_link | ||||||
|  |       content_variables[parameter_index.to_s] = media_link | ||||||
|  |       parameter_index + 1 | ||||||
|  |     else | ||||||
|  |       parameter_index | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_media_link(media_param) | ||||||
|  |     media_param.dig('image', 'link') || | ||||||
|  |       media_param.dig('video', 'link') || | ||||||
|  |       media_param.dig('document', 'link') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_body_parameters(component, content_variables, parameter_index) | ||||||
|  |     component['parameters']&.each do |param| | ||||||
|  |       content_variables[parameter_index.to_s] = param['text'] | ||||||
|  |       parameter_index += 1 | ||||||
|  |     end | ||||||
|  |     parameter_index | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										113
									
								
								app/services/twilio/template_sync_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								app/services/twilio/template_sync_service.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | |||||||
|  | class Twilio::TemplateSyncService | ||||||
|  |   pattr_initialize [:channel!] | ||||||
|  |  | ||||||
|  |   def call | ||||||
|  |     fetch_templates_from_twilio | ||||||
|  |     update_channel_templates | ||||||
|  |     mark_templates_updated | ||||||
|  |   rescue Twilio::REST::TwilioError => e | ||||||
|  |     Rails.logger.error("Twilio template sync failed: #{e.message}") | ||||||
|  |     false | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def fetch_templates_from_twilio | ||||||
|  |     @templates = client.content.v1.contents.list(limit: 1000) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def update_channel_templates | ||||||
|  |     formatted_templates = @templates.map do |template| | ||||||
|  |       { | ||||||
|  |         content_sid: template.sid, | ||||||
|  |         friendly_name: template.friendly_name, | ||||||
|  |         language: template.language, | ||||||
|  |         status: derive_status(template), | ||||||
|  |         template_type: derive_template_type(template), | ||||||
|  |         media_type: derive_media_type(template), | ||||||
|  |         variables: template.variables || {}, | ||||||
|  |         category: derive_category(template), | ||||||
|  |         body: extract_body_content(template), | ||||||
|  |         created_at: template.date_created, | ||||||
|  |         updated_at: template.date_updated | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     channel.update!( | ||||||
|  |       content_templates: { templates: formatted_templates }, | ||||||
|  |       content_templates_last_updated: Time.current | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mark_templates_updated | ||||||
|  |     channel.update!(content_templates_last_updated: Time.current) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def client | ||||||
|  |     @client ||= channel.send(:client) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def derive_status(_template) | ||||||
|  |     # For now, assume all fetched templates are approved | ||||||
|  |     # In the future, this could check approval status from Twilio | ||||||
|  |     'approved' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def derive_template_type(template) | ||||||
|  |     template_types = template.types.keys | ||||||
|  |  | ||||||
|  |     if template_types.include?('twilio/media') | ||||||
|  |       'media' | ||||||
|  |     elsif template_types.include?('twilio/quick-reply') | ||||||
|  |       'quick_reply' | ||||||
|  |     elsif template_types.include?('twilio/catalog') | ||||||
|  |       'catalog' | ||||||
|  |     else | ||||||
|  |       'text' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def derive_media_type(template) | ||||||
|  |     return nil unless derive_template_type(template) == 'media' | ||||||
|  |  | ||||||
|  |     media_content = template.types['twilio/media'] | ||||||
|  |     return nil unless media_content | ||||||
|  |  | ||||||
|  |     if media_content['image'] | ||||||
|  |       'image' | ||||||
|  |     elsif media_content['video'] | ||||||
|  |       'video' | ||||||
|  |     elsif media_content['document'] | ||||||
|  |       'document' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def derive_category(template) | ||||||
|  |     # Map template friendly names or other attributes to categories | ||||||
|  |     # For now, use utility as default | ||||||
|  |     case template.friendly_name | ||||||
|  |     when /marketing|promo|offer|sale/i | ||||||
|  |       'marketing' | ||||||
|  |     when /auth|otp|verify|code/i | ||||||
|  |       'authentication' | ||||||
|  |     else | ||||||
|  |       'utility' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_body_content(template) | ||||||
|  |     template_types = template.types | ||||||
|  |  | ||||||
|  |     if template_types['twilio/text'] | ||||||
|  |       template_types['twilio/text']['body'] | ||||||
|  |     elsif template_types['twilio/media'] | ||||||
|  |       template_types['twilio/media']['body'] | ||||||
|  |     elsif template_types['twilio/quick-reply'] | ||||||
|  |       template_types['twilio/quick-reply']['body'] | ||||||
|  |     elsif template_types['twilio/catalog'] | ||||||
|  |       template_types['twilio/catalog']['body'] | ||||||
|  |     else | ||||||
|  |       '' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -63,9 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? | |||||||
| json.messaging_service_sid resource.channel.try(:messaging_service_sid) | json.messaging_service_sid resource.channel.try(:messaging_service_sid) | ||||||
| json.phone_number resource.channel.try(:phone_number) | json.phone_number resource.channel.try(:phone_number) | ||||||
| json.medium resource.channel.try(:medium) if resource.twilio? | json.medium resource.channel.try(:medium) if resource.twilio? | ||||||
| if resource.twilio? && Current.account_user&.administrator? | if resource.twilio? | ||||||
|   json.auth_token resource.channel.try(:auth_token) |   json.content_templates resource.channel.try(:content_templates) | ||||||
|   json.account_sid resource.channel.try(:account_sid) |   if Current.account_user&.administrator? | ||||||
|  |     json.auth_token resource.channel.try(:auth_token) | ||||||
|  |     json.account_sid resource.channel.try(:account_sid) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
| if resource.email? | if resource.email? | ||||||
|   | |||||||
| @@ -195,3 +195,6 @@ | |||||||
|   display_name: Assignment V2 |   display_name: Assignment V2 | ||||||
|   enabled: false |   enabled: false | ||||||
|   chatwoot_internal: true |   chatwoot_internal: true | ||||||
|  | - name: twilio_content_templates | ||||||
|  |   display_name: Twilio Content Templates | ||||||
|  |   enabled: false | ||||||
|   | |||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | class AddContentTemplatesToTwilioSms < ActiveRecord::Migration[7.1] | ||||||
|  |   def change | ||||||
|  |     add_column :channel_twilio_sms, :content_templates, :jsonb, default: {} | ||||||
|  |     add_column :channel_twilio_sms, :content_templates_last_updated, :datetime | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
|  |  | ||||||
| ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do | ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do | ||||||
|   # These extensions should be enabled to support this database |   # These extensions should be enabled to support this database | ||||||
|   enable_extension "pg_stat_statements" |   enable_extension "pg_stat_statements" | ||||||
|   enable_extension "pg_trgm" |   enable_extension "pg_trgm" | ||||||
| @@ -474,6 +474,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do | |||||||
|     t.integer "medium", default: 0 |     t.integer "medium", default: 0 | ||||||
|     t.string "messaging_service_sid" |     t.string "messaging_service_sid" | ||||||
|     t.string "api_key_sid" |     t.string "api_key_sid" | ||||||
|  |     t.jsonb "content_templates", default: {} | ||||||
|  |     t.datetime "content_templates_last_updated" | ||||||
|     t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true |     t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true | ||||||
|     t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true |     t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true | ||||||
|     t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true |     t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								spec/jobs/channels/twilio/templates_sync_job_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								spec/jobs/channels/twilio/templates_sync_job_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Channels::Twilio::TemplatesSyncJob do | ||||||
|  |   let!(:account) { create(:account) } | ||||||
|  |   let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) } | ||||||
|  |  | ||||||
|  |   it 'enqueues the job' do | ||||||
|  |     expect { described_class.perform_later(twilio_channel) }.to have_enqueued_job(described_class) | ||||||
|  |       .on_queue('low') | ||||||
|  |       .with(twilio_channel) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#perform' do | ||||||
|  |     let(:template_sync_service) { instance_double(Twilio::TemplateSyncService) } | ||||||
|  |  | ||||||
|  |     context 'with successful template sync' do | ||||||
|  |       it 'creates and calls the template sync service' do | ||||||
|  |         expect(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service) | ||||||
|  |         expect(template_sync_service).to receive(:call).and_return(true) | ||||||
|  |  | ||||||
|  |         described_class.perform_now(twilio_channel) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with template sync exception' do | ||||||
|  |       let(:error_message) { 'Twilio API error' } | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         allow(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service) | ||||||
|  |         allow(template_sync_service).to receive(:call).and_raise(StandardError, error_message) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'does not suppress the exception' do | ||||||
|  |         expect { described_class.perform_now(twilio_channel) }.to raise_error(StandardError, error_message) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with nil channel' do | ||||||
|  |       it 'handles nil channel gracefully' do | ||||||
|  |         expect { described_class.perform_now(nil) }.to raise_error(NoMethodError) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'job configuration' do | ||||||
|  |     it 'is configured to run on low priority queue' do | ||||||
|  |       expect(described_class.queue_name).to eq('low') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										598
									
								
								spec/services/twilio/template_processor_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										598
									
								
								spec/services/twilio/template_processor_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,598 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Twilio::TemplateProcessorService do | ||||||
|  |   subject(:processor_service) { described_class.new(channel: twilio_channel, template_params: template_params, message: message) } | ||||||
|  |  | ||||||
|  |   let!(:account) { create(:account) } | ||||||
|  |   let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) } | ||||||
|  |   let!(:contact) { create(:contact, account: account) } | ||||||
|  |   let!(:inbox) { create(:inbox, channel: twilio_channel, account: account) } | ||||||
|  |   let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } | ||||||
|  |   let!(:conversation) { create(:conversation, contact: contact, inbox: inbox, contact_inbox: contact_inbox) } | ||||||
|  |   let!(:message) { create(:message, conversation: conversation, account: account) } | ||||||
|  |  | ||||||
|  |   let(:content_templates) do | ||||||
|  |     { | ||||||
|  |       'templates' => [ | ||||||
|  |         { | ||||||
|  |           'content_sid' => 'HX123456789', | ||||||
|  |           'friendly_name' => 'hello_world', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'text', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => {}, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Hello World!' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           'content_sid' => 'HX987654321', | ||||||
|  |           'friendly_name' => 'greet', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'text', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => { '1' => 'John' }, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Hello {{1}}!' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           'content_sid' => 'HX555666777', | ||||||
|  |           'friendly_name' => 'product_showcase', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'media', | ||||||
|  |           'media_type' => 'image', | ||||||
|  |           'variables' => { '1' => 'https://example.com/image.jpg', '2' => 'iPhone', '3' => '$999' }, | ||||||
|  |           'category' => 'marketing', | ||||||
|  |           'body' => 'Check out {{2}} for {{3}}' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           'content_sid' => 'HX111222333', | ||||||
|  |           'friendly_name' => 'welcome_message', | ||||||
|  |           'language' => 'en_US', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'quick_reply', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => {}, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Welcome! How can we help?' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           'content_sid' => 'HX444555666', | ||||||
|  |           'friendly_name' => 'order_status', | ||||||
|  |           'language' => 'es', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'text', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => { '1' => 'Juan', '2' => 'ORD123' }, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Hola {{1}}, tu pedido {{2}} está confirmado' | ||||||
|  |         } | ||||||
|  |       ] | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     twilio_channel.update!(content_templates: content_templates) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#call' do | ||||||
|  |     context 'with blank template_params' do | ||||||
|  |       let(:template_params) { nil } | ||||||
|  |  | ||||||
|  |       it 'returns nil values' do | ||||||
|  |         result = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to eq([nil, nil]) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with empty template_params' do | ||||||
|  |       let(:template_params) { {} } | ||||||
|  |  | ||||||
|  |       it 'returns nil values' do | ||||||
|  |         result = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to eq([nil, nil]) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with template not found' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'nonexistent_template', | ||||||
|  |           'language' => 'en' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns nil values' do | ||||||
|  |         result = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to eq([nil, nil]) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with text templates' do | ||||||
|  |       context 'with simple text template (no variables)' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'hello_world', | ||||||
|  |             'language' => 'en' | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'returns content_sid and empty variables' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX123456789') | ||||||
|  |           expect(content_variables).to eq({}) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with text template using processed_params format' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'greet', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'processed_params' => { | ||||||
|  |               '1' => 'Alice', | ||||||
|  |               '2' => 'Premium User' | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes key-value parameters correctly' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX987654321') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'Alice', | ||||||
|  |                                             '2' => 'Premium User' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with text template using WhatsApp Cloud API format' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'greet', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Bob' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes WhatsApp format parameters correctly' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX987654321') | ||||||
|  |           expect(content_variables).to eq({ '1' => 'Bob' }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with multiple body parameters' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'greet', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Charlie' }, | ||||||
|  |                   { 'type' => 'text', 'text' => 'VIP Member' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes multiple parameters with sequential indexing' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX987654321') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'Charlie', | ||||||
|  |                                             '2' => 'VIP Member' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with quick reply templates' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'welcome_message', | ||||||
|  |           'language' => 'en_US' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'processes quick reply templates like text templates' do | ||||||
|  |         content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(content_sid).to eq('HX111222333') | ||||||
|  |         expect(content_variables).to eq({}) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with quick reply template having body parameters' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'welcome_message', | ||||||
|  |             'language' => 'en_US', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Diana' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes body parameters for quick reply templates' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX111222333') | ||||||
|  |           expect(content_variables).to eq({ '1' => 'Diana' }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with media templates' do | ||||||
|  |       context 'with media template using processed_params format' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'processed_params' => { | ||||||
|  |               '1' => 'https://cdn.example.com/product.jpg', | ||||||
|  |               '2' => 'MacBook Pro', | ||||||
|  |               '3' => '$2499' | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes key-value parameters for media templates' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'https://cdn.example.com/product.jpg', | ||||||
|  |                                             '2' => 'MacBook Pro', | ||||||
|  |                                             '3' => '$2499' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with media template using WhatsApp Cloud API format' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'header', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { | ||||||
|  |                     'type' => 'image', | ||||||
|  |                     'image' => { 'link' => 'https://example.com/product-image.jpg' } | ||||||
|  |                   } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Samsung Galaxy' }, | ||||||
|  |                   { 'type' => 'text', 'text' => '$899' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes media header and body parameters correctly' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'https://example.com/product-image.jpg', | ||||||
|  |                                             '2' => 'Samsung Galaxy', | ||||||
|  |                                             '3' => '$899' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with video media template' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'header', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { | ||||||
|  |                     'type' => 'video', | ||||||
|  |                     'video' => { 'link' => 'https://example.com/demo.mp4' } | ||||||
|  |                   } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Product Demo' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes video media parameters correctly' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'https://example.com/demo.mp4', | ||||||
|  |                                             '2' => 'Product Demo' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with document media template' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'header', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { | ||||||
|  |                     'type' => 'document', | ||||||
|  |                     'document' => { 'link' => 'https://example.com/brochure.pdf' } | ||||||
|  |                   } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Product Brochure' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes document media parameters correctly' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'https://example.com/brochure.pdf', | ||||||
|  |                                             '2' => 'Product Brochure' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with header parameter without media link' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'header', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Header Text' } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'Body Text' } | ||||||
|  |                 ] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'skips header without media and processes body parameters' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ '1' => 'Body Text' }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with mixed component types' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'product_showcase', | ||||||
|  |             'language' => 'en', | ||||||
|  |             'parameters' => [ | ||||||
|  |               { | ||||||
|  |                 'type' => 'header', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { | ||||||
|  |                     'type' => 'image', | ||||||
|  |                     'image' => { 'link' => 'https://example.com/header.jpg' } | ||||||
|  |                   } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'body', | ||||||
|  |                 'parameters' => [ | ||||||
|  |                   { 'type' => 'text', 'text' => 'First param' }, | ||||||
|  |                   { 'type' => 'text', 'text' => 'Second param' } | ||||||
|  |                 ] | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 'type' => 'footer', | ||||||
|  |                 'parameters' => [] | ||||||
|  |               } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'processes supported components and ignores unsupported ones' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX555666777') | ||||||
|  |           expect(content_variables).to eq({ | ||||||
|  |                                             '1' => 'https://example.com/header.jpg', | ||||||
|  |                                             '2' => 'First param', | ||||||
|  |                                             '3' => 'Second param' | ||||||
|  |                                           }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with language matching' do | ||||||
|  |       context 'with exact language match' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'order_status', | ||||||
|  |             'language' => 'es' | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'finds template with exact language match' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX444555666') | ||||||
|  |           expect(content_variables).to eq({}) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with default language fallback' do | ||||||
|  |         let(:template_params) do | ||||||
|  |           { | ||||||
|  |             'name' => 'hello_world' | ||||||
|  |             # No language specified, should default to 'en' | ||||||
|  |           } | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'defaults to English when no language specified' do | ||||||
|  |           content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |           expect(content_sid).to eq('HX123456789') | ||||||
|  |           expect(content_variables).to eq({}) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with unapproved template status' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'unapproved_template', | ||||||
|  |           'language' => 'en' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         unapproved_template = { | ||||||
|  |           'content_sid' => 'HX_UNAPPROVED', | ||||||
|  |           'friendly_name' => 'unapproved_template', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'pending', | ||||||
|  |           'template_type' => 'text', | ||||||
|  |           'variables' => {}, | ||||||
|  |           'body' => 'This is unapproved' | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         updated_templates = content_templates['templates'] + [unapproved_template] | ||||||
|  |         twilio_channel.update!( | ||||||
|  |           content_templates: { 'templates' => updated_templates } | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'ignores templates that are not approved' do | ||||||
|  |         content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(content_sid).to be_nil | ||||||
|  |         expect(content_variables).to be_nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with unknown template type' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'unknown_type', | ||||||
|  |           'language' => 'en' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         unknown_template = { | ||||||
|  |           'content_sid' => 'HX_UNKNOWN', | ||||||
|  |           'friendly_name' => 'unknown_type', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'catalog', | ||||||
|  |           'variables' => {}, | ||||||
|  |           'body' => 'Catalog template' | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         updated_templates = content_templates['templates'] + [unknown_template] | ||||||
|  |         twilio_channel.update!( | ||||||
|  |           content_templates: { 'templates' => updated_templates } | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns empty content variables for unknown template types' do | ||||||
|  |         content_sid, content_variables = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(content_sid).to eq('HX_UNKNOWN') | ||||||
|  |         expect(content_variables).to eq({}) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'template finding behavior' do | ||||||
|  |     context 'with no content_templates' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'hello_world', | ||||||
|  |           'language' => 'en' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         twilio_channel.update!(content_templates: {}) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns nil values when content_templates is empty' do | ||||||
|  |         result = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to eq([nil, nil]) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with nil content_templates' do | ||||||
|  |       let(:template_params) do | ||||||
|  |         { | ||||||
|  |           'name' => 'hello_world', | ||||||
|  |           'language' => 'en' | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         twilio_channel.update!(content_templates: nil) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns nil values when content_templates is nil' do | ||||||
|  |         result = processor_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to eq([nil, nil]) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										319
									
								
								spec/services/twilio/template_sync_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								spec/services/twilio/template_sync_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Twilio::TemplateSyncService do | ||||||
|  |   subject(:sync_service) { described_class.new(channel: twilio_channel) } | ||||||
|  |  | ||||||
|  |   let!(:account) { create(:account) } | ||||||
|  |   let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) } | ||||||
|  |  | ||||||
|  |   let(:twilio_client) { instance_double(Twilio::REST::Client) } | ||||||
|  |   let(:content_api) { double } | ||||||
|  |   let(:contents_list) { double } | ||||||
|  |  | ||||||
|  |   # Mock Twilio template objects | ||||||
|  |   let(:text_template) do | ||||||
|  |     instance_double( | ||||||
|  |       Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |       sid: 'HX123456789', | ||||||
|  |       friendly_name: 'hello_world', | ||||||
|  |       language: 'en', | ||||||
|  |       date_created: Time.current, | ||||||
|  |       date_updated: Time.current, | ||||||
|  |       variables: {}, | ||||||
|  |       types: { 'twilio/text' => { 'body' => 'Hello World!' } } | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   let(:media_template) do | ||||||
|  |     instance_double( | ||||||
|  |       Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |       sid: 'HX987654321', | ||||||
|  |       friendly_name: 'product_showcase', | ||||||
|  |       language: 'en', | ||||||
|  |       date_created: Time.current, | ||||||
|  |       date_updated: Time.current, | ||||||
|  |       variables: { '1' => 'iPhone', '2' => '$999' }, | ||||||
|  |       types: { | ||||||
|  |         'twilio/media' => { | ||||||
|  |           'body' => 'Check out {{1}} for {{2}}', | ||||||
|  |           'media' => ['https://example.com/image.jpg'] | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   let(:quick_reply_template) do | ||||||
|  |     instance_double( | ||||||
|  |       Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |       sid: 'HX555666777', | ||||||
|  |       friendly_name: 'welcome_message', | ||||||
|  |       language: 'en_US', | ||||||
|  |       date_created: Time.current, | ||||||
|  |       date_updated: Time.current, | ||||||
|  |       variables: {}, | ||||||
|  |       types: { | ||||||
|  |         'twilio/quick-reply' => { | ||||||
|  |           'body' => 'Welcome! How can we help?', | ||||||
|  |           'actions' => [ | ||||||
|  |             { 'id' => 'support', 'title' => 'Support' }, | ||||||
|  |             { 'id' => 'sales', 'title' => 'Sales' } | ||||||
|  |           ] | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   let(:catalog_template) do | ||||||
|  |     instance_double( | ||||||
|  |       Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |       sid: 'HX111222333', | ||||||
|  |       friendly_name: 'product_catalog', | ||||||
|  |       language: 'en', | ||||||
|  |       date_created: Time.current, | ||||||
|  |       date_updated: Time.current, | ||||||
|  |       variables: {}, | ||||||
|  |       types: { | ||||||
|  |         'twilio/catalog' => { | ||||||
|  |           'body' => 'Check our catalog', | ||||||
|  |           'catalog_id' => 'catalog123' | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] } | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     allow(twilio_channel).to receive(:send).and_call_original | ||||||
|  |     allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client) | ||||||
|  |     allow(twilio_client).to receive(:content).and_return(content_api) | ||||||
|  |     allow(content_api).to receive(:v1).and_return(content_api) | ||||||
|  |     allow(content_api).to receive(:contents).and_return(contents_list) | ||||||
|  |     allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#call' do | ||||||
|  |     context 'with successful sync' do | ||||||
|  |       it 'fetches templates from Twilio and updates the channel' do | ||||||
|  |         freeze_time do | ||||||
|  |           result = sync_service.call | ||||||
|  |  | ||||||
|  |           expect(result).to be_truthy | ||||||
|  |           expect(contents_list).to have_received(:list).with(limit: 1000) | ||||||
|  |  | ||||||
|  |           twilio_channel.reload | ||||||
|  |           expect(twilio_channel.content_templates).to be_present | ||||||
|  |           expect(twilio_channel.content_templates['templates']).to be_an(Array) | ||||||
|  |           expect(twilio_channel.content_templates['templates'].size).to eq(4) | ||||||
|  |           expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'correctly formats text templates' do | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         text_template_data = twilio_channel.content_templates['templates'].find do |t| | ||||||
|  |           t['friendly_name'] == 'hello_world' | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         expect(text_template_data).to include( | ||||||
|  |           'content_sid' => 'HX123456789', | ||||||
|  |           'friendly_name' => 'hello_world', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'text', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => {}, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Hello World!' | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'correctly formats media templates' do | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         media_template_data = twilio_channel.content_templates['templates'].find do |t| | ||||||
|  |           t['friendly_name'] == 'product_showcase' | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         expect(media_template_data).to include( | ||||||
|  |           'content_sid' => 'HX987654321', | ||||||
|  |           'friendly_name' => 'product_showcase', | ||||||
|  |           'language' => 'en', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'media', | ||||||
|  |           'media_type' => nil, # Would be derived from media content if present | ||||||
|  |           'variables' => { '1' => 'iPhone', '2' => '$999' }, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Check out {{1}} for {{2}}' | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'correctly formats quick reply templates' do | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         quick_reply_template_data = twilio_channel.content_templates['templates'].find do |t| | ||||||
|  |           t['friendly_name'] == 'welcome_message' | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         expect(quick_reply_template_data).to include( | ||||||
|  |           'content_sid' => 'HX555666777', | ||||||
|  |           'friendly_name' => 'welcome_message', | ||||||
|  |           'language' => 'en_US', | ||||||
|  |           'status' => 'approved', | ||||||
|  |           'template_type' => 'quick_reply', | ||||||
|  |           'media_type' => nil, | ||||||
|  |           'variables' => {}, | ||||||
|  |           'category' => 'utility', | ||||||
|  |           'body' => 'Welcome! How can we help?' | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'categorizes marketing templates correctly' do | ||||||
|  |         marketing_template = instance_double( | ||||||
|  |           Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |           sid: 'HX_MARKETING', | ||||||
|  |           friendly_name: 'promo_offer_50_off', | ||||||
|  |           language: 'en', | ||||||
|  |           date_created: Time.current, | ||||||
|  |           date_updated: Time.current, | ||||||
|  |           variables: {}, | ||||||
|  |           types: { 'twilio/text' => { 'body' => '50% off sale!' } } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template]) | ||||||
|  |  | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         marketing_data = twilio_channel.content_templates['templates'].first | ||||||
|  |  | ||||||
|  |         expect(marketing_data['category']).to eq('marketing') | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'categorizes authentication templates correctly' do | ||||||
|  |         auth_template = instance_double( | ||||||
|  |           Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |           sid: 'HX_AUTH', | ||||||
|  |           friendly_name: 'otp_verification', | ||||||
|  |           language: 'en', | ||||||
|  |           date_created: Time.current, | ||||||
|  |           date_updated: Time.current, | ||||||
|  |           variables: {}, | ||||||
|  |           types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template]) | ||||||
|  |  | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         auth_data = twilio_channel.content_templates['templates'].first | ||||||
|  |  | ||||||
|  |         expect(auth_data['category']).to eq('authentication') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with API error' do | ||||||
|  |       before do | ||||||
|  |         allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error')) | ||||||
|  |         allow(Rails.logger).to receive(:error) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'handles Twilio::REST::TwilioError gracefully' do | ||||||
|  |         result = sync_service.call | ||||||
|  |  | ||||||
|  |         expect(result).to be_falsey | ||||||
|  |         expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with generic error' do | ||||||
|  |       before do | ||||||
|  |         allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed') | ||||||
|  |         allow(Rails.logger).to receive(:error) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'propagates non-Twilio errors' do | ||||||
|  |         expect { sync_service.call }.to raise_error(StandardError, 'Connection failed') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with empty templates list' do | ||||||
|  |       before do | ||||||
|  |         allow(contents_list).to receive(:list).with(limit: 1000).and_return([]) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'updates channel with empty templates array' do | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         expect(twilio_channel.content_templates['templates']).to eq([]) | ||||||
|  |         expect(twilio_channel.content_templates_last_updated).to be_present | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'template categorization behavior' do | ||||||
|  |     it 'defaults to utility category for unrecognized patterns' do | ||||||
|  |       generic_template = instance_double( | ||||||
|  |         Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |         sid: 'HX_GENERIC', | ||||||
|  |         friendly_name: 'order_status', | ||||||
|  |         language: 'en', | ||||||
|  |         date_created: Time.current, | ||||||
|  |         date_updated: Time.current, | ||||||
|  |         variables: {}, | ||||||
|  |         types: { 'twilio/text' => { 'body' => 'Order updated' } } | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template]) | ||||||
|  |  | ||||||
|  |       sync_service.call | ||||||
|  |  | ||||||
|  |       twilio_channel.reload | ||||||
|  |       template_data = twilio_channel.content_templates['templates'].first | ||||||
|  |  | ||||||
|  |       expect(template_data['category']).to eq('utility') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'template type detection' do | ||||||
|  |     context 'with multiple type definitions' do | ||||||
|  |       let(:mixed_template) do | ||||||
|  |         instance_double( | ||||||
|  |           Twilio::REST::Content::V1::ContentInstance, | ||||||
|  |           sid: 'HX_MIXED', | ||||||
|  |           friendly_name: 'mixed_type', | ||||||
|  |           language: 'en', | ||||||
|  |           date_created: Time.current, | ||||||
|  |           date_updated: Time.current, | ||||||
|  |           variables: {}, | ||||||
|  |           types: { | ||||||
|  |             'twilio/media' => { 'body' => 'Media content' }, | ||||||
|  |             'twilio/text' => { 'body' => 'Text content' } | ||||||
|  |           } | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template]) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'prioritizes media type for type detection but text for body extraction' do | ||||||
|  |         sync_service.call | ||||||
|  |  | ||||||
|  |         twilio_channel.reload | ||||||
|  |         template_data = twilio_channel.content_templates['templates'].first | ||||||
|  |  | ||||||
|  |         # derive_template_type prioritizes media | ||||||
|  |         expect(template_data['template_type']).to eq('media') | ||||||
|  |         # but extract_body_content prioritizes text | ||||||
|  |         expect(template_data['body']).to eq('Text content') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth