mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +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 | ||||
|  | ||||
|   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' } | ||||
|     end | ||||
|     return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? | ||||
|  | ||||
|     Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) | ||||
|     trigger_template_sync | ||||
|     render status: :ok, json: { message: 'Template sync initiated successfully' } | ||||
|   rescue StandardError => e | ||||
|     render status: :internal_server_error, json: { error: e.message } | ||||
| @@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | ||||
|       [] | ||||
|     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 | ||||
|  | ||||
| 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 | ||||
| @@ -6,6 +6,8 @@ | ||||
| #  account_sid                    :string           not null | ||||
| #  api_key_sid                    :string | ||||
| #  auth_token                     :string           not null | ||||
| #  content_templates              :jsonb | ||||
| #  content_templates_last_updated :datetime | ||||
| #  medium                         :integer          default("sms") | ||||
| #  messaging_service_sid          :string | ||||
| #  phone_number                   :string | ||||
|   | ||||
| @@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService | ||||
|  | ||||
|   def perform_reply | ||||
|     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 | ||||
|       Messages::StatusUpdateService.new(message, 'failed', e.message).perform | ||||
|     end | ||||
|     message.update!(source_id: twilio_message.sid) if twilio_message | ||||
|   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 | ||||
|     { | ||||
|       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,10 +63,13 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? | ||||
| json.messaging_service_sid resource.channel.try(:messaging_service_sid) | ||||
| json.phone_number resource.channel.try(:phone_number) | ||||
| json.medium resource.channel.try(:medium) if resource.twilio? | ||||
| if resource.twilio? && Current.account_user&.administrator? | ||||
| if resource.twilio? | ||||
|   json.content_templates resource.channel.try(:content_templates) | ||||
|   if Current.account_user&.administrator? | ||||
|     json.auth_token resource.channel.try(:auth_token) | ||||
|     json.account_sid resource.channel.try(:account_sid) | ||||
|   end | ||||
| end | ||||
|  | ||||
| if resource.email? | ||||
|   ## Email Channel Attributes | ||||
|   | ||||
| @@ -195,3 +195,6 @@ | ||||
|   display_name: Assignment V2 | ||||
|   enabled: false | ||||
|   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. | ||||
|  | ||||
| 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 | ||||
|   enable_extension "pg_stat_statements" | ||||
|   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.string "messaging_service_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 ["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 | ||||
|   | ||||
							
								
								
									
										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