Browse Source

Maintenance: Ensure correct functionality of Twitter integration.

Florian Liebe 2 years ago
parent
commit
7be2fb875a

+ 33 - 33
app/jobs/communicate_twitter_job.rb

@@ -73,42 +73,42 @@ class CommunicateTwitterJob < ApplicationJob
     # regular tweet
     elsif tweet.instance_of?(Twitter::Tweet)
       tweet_type = 'Tweet'
-      tweet_id = tweet.id.to_s
+
       article.from = "@#{tweet.user.screen_name}"
-      if tweet.user_mentions
-        to = ''
-        mention_ids = []
-        tweet.user_mentions.each do |user|
-          if to != ''
-            to += ' '
-          end
-          to += "@#{user.screen_name}"
-          mention_ids.push user.id
+
+      to = ''
+      mention_ids = []
+      tweet.user_mentions.each do |user|
+        if to != ''
+          to += ' '
         end
-        article.to = to
-        article.preferences['twitter'] = TwitterSync.preferences_cleanup(
-          mention_ids:         mention_ids,
-          geo:                 tweet.geo,
-          retweeted:           tweet.retweeted?,
-          possibly_sensitive:  tweet.possibly_sensitive?,
-          in_reply_to_user_id: tweet.in_reply_to_user_id,
-          place:               tweet.place,
-          retweet_count:       tweet.retweet_count,
-          source:              tweet.source,
-          favorited:           tweet.favorited?,
-          truncated:           tweet.truncated?,
-          created_at:          tweet.created_at,
-        )
-
-        article.message_id = tweet_id
-        article.preferences['links'] = [
-          {
-            url:    TwitterSync::STATUS_URL_TEMPLATE % tweet.id,
-            target: '_blank',
-            name:   'on Twitter',
-          },
-        ]
+        to += "@#{user.screen_name}"
+        mention_ids.push user.id
       end
+      article.to = to
+
+      article.preferences['twitter'] = TwitterSync.preferences_cleanup(
+        mention_ids:         mention_ids,
+        geo:                 tweet.geo,
+        retweeted:           tweet.retweeted?,
+        possibly_sensitive:  tweet.possibly_sensitive?,
+        in_reply_to_user_id: tweet.in_reply_to_user_id,
+        place:               tweet.place,
+        retweet_count:       tweet.retweet_count,
+        source:              tweet.source,
+        favorited:           tweet.favorited?,
+        truncated:           tweet.truncated?,
+        created_at:          tweet.created_at,
+      )
+
+      article.message_id = tweet.id.to_s
+      article.preferences['links'] = [
+        {
+          url:    TwitterSync::STATUS_URL_TEMPLATE % tweet.id,
+          target: '_blank',
+          name:   'on Twitter',
+        },
+      ]
     else
       raise "Unknown tweet type '#{tweet.class}'"
     end

+ 10 - 33
app/models/channel/driver/twitter.rb

@@ -169,21 +169,23 @@ returns
 
       result_type = search[:type] || 'mixed'
       Rails.logger.debug { " - searching for '#{search[:term]}'" }
+      Rails.logger.debug { " - result_type '#{result_type}'" }
       older_import = 0
       older_import_max = 20
-      @client.client.search(search[:term], result_type: result_type).collect do |tweet|
+
+      search_result = @client.client.search(search[:term], result_type: result_type)
+      Rails.logger.debug { " - found #{search_result.attrs[:statuses].count} tweets" }
+      search_result.collect do |tweet|
         next if !track_retweets? && tweet.retweet?
 
         # ignore older messages
-        if @sync[:import_older_tweets] != true
-          if (@channel.created_at - 15.days) > tweet.created_at.dup.utc || older_import >= older_import_max # rubocop:disable Style/SoleNestedConditional
-            older_import += 1
-            Rails.logger.debug { "tweet to old: #{tweet.id}/#{tweet.created_at}" }
-            next
-          end
+        if @sync[:import_older_tweets] != true && ((@channel.created_at - 15.days) > tweet.created_at.dup.utc || older_import >= older_import_max)
+          older_import += 1
+          Rails.logger.debug { "tweet to old: #{tweet.id}/#{tweet.created_at}" }
+          next
         end
 
-        next if @client.locale_sender?(tweet) && own_tweet_already_imported?(tweet)
+        next if @client.locale_sender?(tweet)
         next if Ticket::Article.exists?(message_id: tweet.id)
         break if @client.tweet_limit_reached(tweet)
 
@@ -195,29 +197,4 @@ returns
   def track_retweets?
     @channel.options && @channel.options['sync'] && @channel.options['sync']['track_retweets']
   end
-
-  def own_tweet_already_imported?(tweet)
-    event_time = Time.zone.now
-    sleep 4
-    12.times do |loop_count|
-      if Ticket::Article.exists?(message_id: tweet.id)
-        Rails.logger.debug { "Own tweet already imported, skipping tweet #{tweet.id}" }
-        return true
-      end
-      count = Delayed::Job.where('created_at < ?', event_time).count
-      break if count.zero?
-
-      sleep_time = 2 * count
-      sleep_time = 5 if sleep_time > 5
-      Rails.logger.debug { "Delay importing own tweets - sleep #{sleep_time} (loop #{loop_count})" }
-      sleep sleep_time
-    end
-
-    if Ticket::Article.exists?(message_id: tweet.id)
-      Rails.logger.debug { "Own tweet already imported, skipping tweet #{tweet.id}" }
-      return true
-    end
-    false
-  end
-
 end

+ 4 - 2
lib/twitter_sync.rb

@@ -5,7 +5,9 @@ require 'http/uri'
 class TwitterSync
 
   STATUS_URL_TEMPLATE = 'https://twitter.com/_/status/%s'.freeze
-  DM_URL_TEMPLATE = 'https://twitter.com/messages/%s'.freeze
+  DM_URL_TEMPLATE     = 'https://twitter.com/messages/%s'.freeze
+
+  MAX_TWEETS_PER_IMPORT = 120
 
   attr_accessor :client
 
@@ -548,7 +550,7 @@ create a tweet or direct message from an article
   end
 
   def tweet_limit_reached(tweet, factor = 1)
-    max_count = 120
+    max_count = MAX_TWEETS_PER_IMPORT
     max_count *= factor
     type_id = Ticket::Article::Type.lookup(name: 'twitter status').id
     created_at = 15.minutes.ago

+ 10 - 6
spec/factories/channel.rb

@@ -41,13 +41,15 @@ FactoryBot.define do
           adapter:                  'twitter',
           user:                     {
             id:          oauth_token&.split('-')&.first,
-            screen_name: 'nicole_braun',
-            name:        'Nicole Braun',
+            screen_name: 'APITesting001',
+            name:        'Test API Account',
           },
           auth:                     {
             external_credential_id: external_credential.id,
             oauth_token:            oauth_token,
             oauth_token_secret:     oauth_token_secret,
+            consumer_key:           consumer_key,
+            consumer_secret:        consumer_secret,
           },
           sync:                     {
             webhook_id:      '',
@@ -69,11 +71,13 @@ FactoryBot.define do
       end
 
       transient do
-        custom_options { {} }
+        custom_options      { {} }
         external_credential { create(:twitter_credential) }
-        oauth_token { external_credential.credentials[:oauth_token] }
-        oauth_token_secret { external_credential.credentials[:oauth_token_secret] }
-        search_term { 'zammad' }
+        oauth_token         { external_credential.credentials[:oauth_token] }
+        oauth_token_secret  { external_credential.credentials[:oauth_token_secret] }
+        consumer_key        { external_credential.credentials[:consumer_key] }
+        consumer_secret     { external_credential.credentials[:consumer_secret] }
+        search_term         { 'zammad' }
       end
 
       trait :legacy do

+ 79 - 0
spec/fixtures/files/external_credentials/twitter/zammad_testing.json

@@ -0,0 +1,79 @@
+{
+    "id": 1408314039470538752,
+    "id_str": "1408314039470538752",
+    "name": "Test API Account",
+    "screen_name": "APITesting001",
+    "location": "",
+    "description": "Test API Account",
+    "url": null,
+    "entities": {
+        "description": {
+            "urls": []
+        }
+    },
+    "protected": false,
+    "followers_count": 1,
+    "friends_count": 2,
+    "listed_count": 0,
+    "created_at": "Fri Jun 25 07:21:40 +0000 2021",
+    "favourites_count": 0,
+    "utc_offset": null,
+    "time_zone": null,
+    "geo_enabled": false,
+    "verified": false,
+    "statuses_count": 123,
+    "lang": null,
+    "status": {
+        "created_at": "Wed Oct 12 20:01:37 +0000 2022",
+        "id": 1580287595506208768,
+        "id_str": "1580287595506208768",
+        "text": "Rerum fuga exercitationem perspiciatis.",
+        "truncated": false,
+        "entities": {
+            "hashtags": [],
+            "symbols": [],
+            "user_mentions": [],
+            "urls": []
+        },
+        "source": "<a href=\"https://zammad.com\" rel=\"nofollow\">Zammad Integration Test</a>",
+        "in_reply_to_status_id": null,
+        "in_reply_to_status_id_str": null,
+        "in_reply_to_user_id": null,
+        "in_reply_to_user_id_str": null,
+        "in_reply_to_screen_name": null,
+        "geo": null,
+        "coordinates": null,
+        "place": null,
+        "contributors": null,
+        "is_quote_status": false,
+        "retweet_count": 0,
+        "favorite_count": 0,
+        "favorited": false,
+        "retweeted": false,
+        "lang": "ca"
+    },
+    "contributors_enabled": false,
+    "is_translator": false,
+    "is_translation_enabled": false,
+    "profile_background_color": "F5F8FA",
+    "profile_background_image_url": null,
+    "profile_background_image_url_https": null,
+    "profile_background_tile": false,
+    "profile_image_url": "http://pbs.twimg.com/profile_images/1408343046731845632/K56uhLP2_normal.png",
+    "profile_image_url_https": "https://pbs.twimg.com/profile_images/1408343046731845632/K56uhLP2_normal.png",
+    "profile_link_color": "1DA1F2",
+    "profile_sidebar_border_color": "C0DEED",
+    "profile_sidebar_fill_color": "DDEEF6",
+    "profile_text_color": "333333",
+    "profile_use_background_image": true,
+    "has_extended_profile": true,
+    "default_profile": true,
+    "default_profile_image": false,
+    "following": false,
+    "follow_request_sent": false,
+    "notifications": false,
+    "translator_type": "none",
+    "withheld_in_countries": [],
+    "suspended": false,
+    "needs_phone_verification": false
+}

+ 12 - 13
spec/jobs/communicate_twitter_job_spec.rb

@@ -26,9 +26,9 @@ RSpec.describe CommunicateTwitterJob, required_envs: %w[TWITTER_CONSUMER_KEY TWI
 
       let(:links_array) do
         [{
-          url:    'https://twitter.com/_/status/1410147100403417088',
-          target: '_blank',
-          name:   'on Twitter',
+          'url'    => "https://twitter.com/_/status/#{article.reload.message_id}",
+          'target' => '_blank',
+          'name'   => 'on Twitter',
         }]
       end
 
@@ -45,11 +45,11 @@ RSpec.describe CommunicateTwitterJob, required_envs: %w[TWITTER_CONSUMER_KEY TWI
           .with(body: "in_reply_to_status_id&status=#{CGI.escape(article.body)}")
       end
 
-      it 'updates the article with tweet attributes' do
-        expect { described_class.perform_now(article.id) }
-          .to change { article.reload.message_id }.to('1410147100403417088')
-          .and change { article.reload.preferences[:twitter] }.to(hash_including(tweet_attributes))
-          .and change { article.reload.preferences[:links] }.to(links_array)
+      it 'updates the article with tweet attributes', :aggregate_failures do
+        described_class.perform_now(article.id)
+
+        expect(article.reload.preferences[:twitter]).to include(tweet_attributes)
+        expect(article.reload.preferences[:links]).to eq(links_array)
       end
 
       it 'sets the appropriate delivery status attributes' do
@@ -60,11 +60,11 @@ RSpec.describe CommunicateTwitterJob, required_envs: %w[TWITTER_CONSUMER_KEY TWI
       end
 
       context 'with a user mention' do
-        let(:factory_options) { { body: '@zammadtesting Don’t mind me, just testing the API' } }
+        let(:factory_options) { { body: "@APITesting001 Don't mind me, just testing the API.\n#{Faker::Lorem.sentence}" } }
 
         it 'updates the article with tweet recipients' do
           expect { described_class.perform_now(article.id) }
-            .to change { article.reload.to }.to('@ZammadTesting')
+            .to change { article.reload.to }.to('@APITesting001')
         end
       end
     end
@@ -82,7 +82,7 @@ RSpec.describe CommunicateTwitterJob, required_envs: %w[TWITTER_CONSUMER_KEY TWI
 
       let(:links_array) do
         [{
-          url:    "https://twitter.com/messages/#{recipient.uid}-1408314039470538752",
+          url:    "https://twitter.com/messages/1408314039470538752-#{recipient.uid}",
           target: '_blank',
           name:   'on Twitter',
         }]
@@ -102,8 +102,7 @@ RSpec.describe CommunicateTwitterJob, required_envs: %w[TWITTER_CONSUMER_KEY TWI
 
       it 'updates the article with DM attributes' do
         expect { described_class.perform_now(article.id) }
-          .to change { article.reload.message_id }.to('1410145389026676741')
-          .and change { article.reload.preferences[:twitter] }.to(hash_including(dm_attributes))
+          .to change { article.reload.preferences[:twitter] }.to(hash_including(dm_attributes))
           .and change { article.reload.preferences[:links] }.to(links_array)
       end
 

+ 218 - 0
spec/models/channel/driver/twitter/account_activity_api_spec.rb

@@ -0,0 +1,218 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+require 'rails_helper'
+
+RSpec.describe 'Twitter > Account Activity API', :use_vcr, integration: true, required_envs: %w[TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_OAUTH_TOKEN TWITTER_OAUTH_TOKEN_SECRET TWITTER_USER_ID TWITTER_DM_RECIPIENT TWITTER_SEARCH_CONSUMER_KEY TWITTER_SEARCH_CONSUMER_SECRET TWITTER_SEARCH_OAUTH_TOKEN TWITTER_SEARCH_OAUTH_TOKEN_SECRET TWITTER_SEARCH_USER_ID] do # rubocop:disable RSpec/DescribeClass
+  subject(:channel) { create(:twitter_channel, custom_options: { sync: { search: nil } }) }
+
+  let(:twitter_helper) do
+    RSpecTwitter::Helper.new(auth_data_search_app)
+  end
+
+  let(:twitter_helper_channel) do
+    RSpecTwitter::Helper.new(auth_data_channel_app)
+  end
+
+  let(:channel_attributes) do
+    {
+      'status_in'    => 'ok',
+      'last_log_in'  => '',
+      'status_out'   => nil,
+      'last_log_out' => nil,
+    }
+  end
+
+  def auth_data_channel_app
+    {
+      consumer_key:       ENV['TWITTER_CONSUMER_KEY'],
+      consumer_secret:    ENV['TWITTER_CONSUMER_SECRET'],
+      oauth_token:        ENV['TWITTER_OAUTH_TOKEN'],
+      oauth_token_secret: ENV['TWITTER_OAUTH_TOKEN_SECRET'],
+    }
+  end
+
+  def auth_data_search_app
+    {
+      consumer_key:       ENV['TWITTER_SEARCH_CONSUMER_KEY'],
+      consumer_secret:    ENV['TWITTER_SEARCH_CONSUMER_SECRET'],
+      oauth_token:        ENV['TWITTER_SEARCH_OAUTH_TOKEN'],
+      oauth_token_secret: ENV['TWITTER_SEARCH_OAUTH_TOKEN_SECRET'],
+    }
+  end
+
+  before :all do # rubocop:disable RSpec/BeforeAfterAll
+    if %w[1 true].include?(ENV['CI_IGNORE_CASSETTES'])
+      RSpecTwitter::Helper.new(auth_data_search_app).delete_old_tweets
+      RSpecTwitter::Helper.new(auth_data_channel_app).delete_old_tweets
+    end
+  end
+
+  it 'sets successful status attributes' do
+    expect { channel.fetch }
+      .to change { channel.reload.attributes }
+      .to hash_including(channel_attributes)
+  end
+
+  context 'with search term configured' do
+    subject(:channel) { create(:twitter_channel, custom_options: { sync: { search: [ { term: identifier, group_id: Group.first.id } ] } }) }
+
+    let(:identifier) do
+      random_number = %w[1 true].include?(ENV['CI_IGNORE_CASSETTES']) ? SecureRandom.uuid.delete('-') : '0509d41afd66476fa52a1c3892f669eb'
+
+      "zammad_testing_#{random_number}"
+    end
+
+    let(:ticket_title) { "Come and join our team to bring Zammad even further forward! #{identifier}" }
+
+    after do
+      twitter_helper.delete_all_tweets(identifier)
+      twitter_helper_channel.delete_all_tweets(identifier)
+    end
+
+    context 'with recent tweets' do
+      before do
+        twitter_helper.create_tweet(ticket_title)
+        twitter_helper.create_tweet("dummy API activity test! #{identifier}")
+
+        twitter_helper_channel.ensure_tweet_availability(identifier, 2)
+      end
+
+      let(:expected_ticket_attributes) do
+        {
+          'title'       => ticket_title.size > 80 ? "#{ticket_title[0..79]}..." : ticket_title,
+          'preferences' => {
+            'channel_id'          => channel.id,
+            'channel_screen_name' => channel.options[:user][:screen_name]
+          },
+        }
+      end
+
+      it 'creates an article for each recent tweet', :aggregate_failures do
+        expect { channel.fetch }.to change(Ticket, :count).by(2)
+
+        expect(Ticket.last.attributes).to include(expected_ticket_attributes)
+      end
+    end
+
+    context 'with responses to other tweets' do
+      before do
+        parent_tweet = twitter_helper.create_tweet('Parent tweet without identifier')
+        twitter_helper.create_tweet("Response test! #{identifier}", in_reply_to_status_id: parent_tweet.id)
+
+        twitter_helper_channel.ensure_tweet_availability(identifier, 1)
+      end
+
+      let(:ticket_articles) { Ticket.last.articles }
+
+      it 'creates articles for parent tweets as well', :aggregate_failures do
+        expect { channel.fetch }.to change(Ticket, :count).by(1)
+
+        expect(ticket_articles.first.body).not_to include(identifier)  # parent tweet
+        expect(ticket_articles.last.body).to include(identifier)       # search result
+      end
+    end
+
+    context 'with "track_retweets" option' do
+      before do
+        tweet = twitter_helper_channel.create_tweet("Zammad is amazing! #{identifier}")
+        twitter_helper.create_retweet(tweet.id)
+
+        twitter_helper_channel.ensure_tweet_availability(identifier, 2)
+      end
+
+      context 'when set to false' do
+        it 'skips retweets' do
+          expect { channel.fetch }
+            .not_to change { Ticket.where('title LIKE ?', 'RT @%').count }.from(0)
+        end
+      end
+
+      context 'when set to true' do
+        subject(:channel) { create(:twitter_channel, custom_options: { sync: { track_retweets: true, search: [ { term: identifier, group_id: Group.first.id } ] } }) }
+
+        it 'creates an article for each recent tweet/retweet' do
+          expect { channel.fetch }
+            .to change { Ticket.where('title LIKE ?', 'RT @%').count }.by(1)
+            .and change(Ticket, :count).by(1)
+        end
+      end
+    end
+
+    context 'with "import_older_tweets" option' do
+      before do
+        twitter_helper.create_tweet("Zammad is amazing! #{identifier}")
+        twitter_helper.create_tweet("Such. A. Beautiful. Helpdesk. Tool. #{identifier}")
+        twitter_helper.create_tweet("Need a helpdesk tool? Zammad <3 #{identifier}")
+        twitter_helper_channel.ensure_tweet_availability(identifier, 3)
+
+        travel 16.days
+        channel.update!(created_at: Time.zone.now.utc)
+        travel_back
+      end
+
+      context 'when false (default)' do
+        it 'skips tweets 15+ days older than channel itself' do
+          expect { channel.fetch }.not_to change(Ticket, :count)
+        end
+      end
+
+      context 'when true' do
+        subject(:channel) { create(:twitter_channel, custom_options: { sync: { import_older_tweets: true, search: [ { term: identifier, group_id: Group.first.id } ] } }) }
+
+        it 'creates an article for each tweet' do
+          expect { channel.fetch }.to change(Ticket, :count).by(3)
+        end
+      end
+    end
+
+    context 'when fetched tweets have already been imported' do
+      before do
+        tweet_ids = []
+        3.times do |index|
+          tweet = twitter_helper.create_tweet("Tweet #{index}! #{identifier}")
+
+          tweet_ids << tweet.id
+        end
+        twitter_helper_channel.ensure_tweet_availability(identifier, 3)
+
+        tweet_ids.each { |tweet_id| create(:ticket_article, message_id: tweet_id) }
+      end
+
+      it 'does not import duplicates' do
+        expect { channel.fetch }.not_to change(Ticket::Article, :count)
+      end
+    end
+
+    context 'with a very common search term' do
+      subject(:channel) { create(:twitter_channel, custom_options: { sync: { search: [ { term: 'corona', group_id: Group.first.id } ] } }) }
+
+      let(:twitter_articles) { Ticket::Article.joins(:type).where(ticket_article_types: { name: 'twitter status' }) }
+
+      before do
+        stub_const('TwitterSync::MAX_TWEETS_PER_IMPORT', 10)
+      end
+
+      # Note that this rate limiting is partially duplicated
+      # in #fetchable?, which prevents #fetch from running
+      # more than once in a 20-minute period.
+      it 'imports max. ~120 articles every 15 minutes', :aggregate_failures do
+        freeze_time
+
+        channel.fetch
+
+        expect((twitter_articles - Ticket.last.articles).count).to be <= 10
+        expect(twitter_articles.count).to be > 10
+
+        travel(10.minutes)
+
+        expect { channel.fetch }.not_to change(Ticket::Article, :count)
+
+        travel(6.minutes)
+
+        expect { channel.fetch }.to change(Ticket::Article, :count)
+
+        travel_back
+      end
+    end
+  end
+end

+ 2 - 194
spec/models/channel/driver/twitter_spec.rb

@@ -5,8 +5,6 @@ require 'rails_helper'
 RSpec.describe Channel::Driver::Twitter, required_envs: %w[TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_OAUTH_TOKEN TWITTER_OAUTH_TOKEN_SECRET TWITTER_DM_RECIPIENT TWITTER_USER_ID] do
   subject(:channel) { create(:twitter_channel) }
 
-  let(:external_credential) { ExternalCredential.find(channel.options[:auth][:external_credential_id]) }
-
   describe '#process', current_user_id: 1 do
     # Twitter channels must be configured to know whose account they're monitoring.
     subject(:channel) do
@@ -736,7 +734,7 @@ RSpec.describe Channel::Driver::Twitter, required_envs: %w[TWITTER_CONSUMER_KEY
     end
 
     context 'for DMs' do
-      let(:recipient) { create(:twitter_authorization, uid: ENV.fetch('TWITTER_DM_RECIPIENT', '1234567890')) }
+      let(:recipient)       { create(:twitter_authorization, uid: ENV.fetch('TWITTER_DM_RECIPIENT', '1234567890')) }
       let!(:outgoing_tweet) { create(:twitter_dm_article, :pending_delivery, recipient: recipient) }
       let(:return_value)    { Twitter::DirectMessage }
 
@@ -775,7 +773,7 @@ RSpec.describe Channel::Driver::Twitter, required_envs: %w[TWITTER_CONSUMER_KEY
       end
 
       context '20+ minutes since last run' do
-        before { travel(20.minutes) }
+        before { travel(21.minutes) }
 
         it 'runs again' do
           expect { channel.fetch }
@@ -783,195 +781,5 @@ RSpec.describe Channel::Driver::Twitter, required_envs: %w[TWITTER_CONSUMER_KEY
         end
       end
     end
-
-    describe 'Twitter API activity' do
-      it 'sets successful status attributes' do
-        expect { channel.fetch }
-          .to change { channel.reload.attributes }
-          .to hash_including(
-            'status_in'    => 'ok',
-            'last_log_in'  => '',
-            'status_out'   => nil,
-            'last_log_out' => nil
-          )
-      end
-
-      context 'with search term configured (at .options[:sync][:search])' do
-        it 'creates an article for each recent tweet' do
-          expect { channel.fetch }
-            .to change(Ticket, :count).by(2)
-
-          expect(Ticket.last.attributes).to include(
-            'title'       => "Come and join our team to bring Zammad even further forward!   It's gonna be ama...",
-            'preferences' => { 'channel_id'          => channel.id,
-                               'channel_screen_name' => channel.options[:user][:screen_name] },
-            'customer_id' => User.find_by(firstname: 'Mr.Generation', lastname: '').id
-          )
-        end
-
-        context 'for responses to other tweets' do
-          let(:thread) do
-            Ticket.joins(articles: :type).where(ticket_article_types: { name: 'twitter status' })
-              .group('tickets.id').having(
-                case ActiveRecord::Base.connection_db_config.configuration_hash[:adapter]
-                when 'mysql2'
-                  'COUNT("ticket_articles.*") > 1'
-                when 'postgresql'
-                  'COUNT(ticket_articles.*) > 1'
-                end
-              ).first
-          end
-
-          it 'creates articles for parent tweets as well' do
-            channel.fetch
-
-            expect(thread.articles.last.body).to match(%r{zammad}i)       # search result
-            expect(thread.articles.first.body).not_to match(%r{zammad}i)  # parent tweet
-          end
-        end
-
-        context 'and "track_retweets" option' do
-          context 'is false (default)' do
-            it 'skips retweets' do
-              expect { channel.fetch }
-                .not_to change { Ticket.where('title LIKE ?', 'RT @%').count }.from(0)
-            end
-          end
-
-          context 'is true' do
-            subject(:channel) { create(:twitter_channel, custom_options: { sync: { track_retweets: true } }) }
-
-            it 'creates an article for each recent tweet/retweet' do
-              expect { channel.fetch }
-                .to change { Ticket.where('title LIKE ?', 'RT @%').count }.by(49)
-                .and change(Ticket, :count).by(73)
-            end
-          end
-        end
-
-        context 'and "import_older_tweets" option (legacy)' do
-          context 'is false (default)' do
-            it 'skips tweets 15+ days older than channel itself' do
-              expect { channel.fetch }
-                .not_to change { Ticket.where('title LIKE ?', 'GitHub Trending Archive, 29 Nov 2018, Ruby. %').count }.from(0)
-            end
-          end
-
-          context 'is true' do
-            subject(:channel) { create(:twitter_channel, :legacy) }
-
-            it 'creates an article for each tweet' do
-              expect { channel.fetch }
-                .to change { Ticket.where('title LIKE ?', 'GitHub Trending Archive, 29 Nov 2018, Ruby. %').count }.by(1)
-                .and change(Ticket, :count).by(3)
-            end
-          end
-        end
-
-        describe 'duplicate handling' do
-          context 'when fetched tweets have already been imported' do
-            before do
-              tweet_ids.each { |tweet_id| create(:ticket_article, message_id: tweet_id) }
-            end
-
-            let(:tweet_ids) { [1222126386334388225, 1222109934923460608] } # rubocop:disable Style/NumericLiterals
-
-            it 'does not import duplicates' do
-              expect { channel.fetch }.not_to change(Ticket::Article, :count)
-            end
-          end
-
-          describe 'Race condition: when #fetch finds a half-processed, outgoing tweet' do
-            subject!(:channel) do
-              create(:twitter_channel,
-                     search_term:    'zammadzammadzammad',
-                     custom_options: {
-                       user: {
-                         # "outgoing" tweets = authored by this Twitter user ID
-                         id: '1205290247124217856',
-                       },
-                     })
-            end
-
-            # This test case requires the use_vcr: :time_sensitive option
-            # to travel_to(when the VCR cassette was recorded).
-            #
-            # This ensures that #fetch doesn't ignore
-            # the "older" tweets stored in the VCR cassette,
-            # but it also freezes time,
-            # which breaks this test expectation logic:
-            #
-            #     expect { channel.fetch }.to change(Time, :current).by_at_least(5)
-            #
-            # So, we unfreeze time here.
-            before { travel_back }
-
-            let!(:tweet) { create(:twitter_article, body: 'zammadzammadzammad') }
-
-            context '(i.e., after the BG job has posted the article to Twitter…' do
-              # NOTE: This context block cannot be set up programmatically.
-              # Instead, the tweet was posted, fetched, recorded into a VCR cassette,
-              # and then manually copied into the existing VCR cassette for this example.
-
-              context '…but before the BG job has "synced" article.message_id with tweet.id)' do
-                let(:twitter_job) { Delayed::Job.where("handler LIKE '%job_class: CommunicateTwitterJob%#{tweet.id}%'").first }
-
-                around do |example|
-                  Thread.new do
-                    sleep 5 # Simulate other bg jobs holding up the queue.
-                    twitter_job.invoke_job
-                  end.tap { example.run }.join
-                end
-
-                it 'does not import the duplicate tweet (waits up to 60s for BG job to finish)' do
-                  expect { channel.fetch }
-                    .to not_change(Ticket::Article, :count)
-                    .and change(Time, :current).by_at_least(5)
-                end
-              end
-            end
-
-            # To reproduce this test case, the VCR cassette has been modified
-            # so that the fetched tweet has a different ("incoming") author user ID.
-            it 'skips race condition handling for incoming tweets' do
-              expect { channel.fetch }
-                .to change(Ticket::Article, :count)
-                .and change(Time, :current).by_at_most(1)
-            end
-          end
-        end
-
-        context 'for very common search terms' do
-          subject(:channel) { create(:twitter_channel, search_term: 'coronavirus') }
-
-          let(:twitter_articles) { Ticket::Article.joins(:type).where(ticket_article_types: { name: 'twitter status' }) }
-
-          # NOTE: Ordinarily, RSpec examples should be kept as small as possible.
-          # In this case, we bundle these examples together because
-          # separating them would duplicate expensive setup:
-          # even with HTTP caching, this single example takes nearly a minute.
-          #
-          # Also, note that this rate limiting is partially duplicated
-          # in #fetchable?, which prevents #fetch from running
-          # more than once in a 20-minute period.
-          it 'imports max. ~120 articles every 15 minutes' do
-            channel.fetch
-
-            expect((twitter_articles - Ticket.last.articles).count).to be <= 120
-            expect(twitter_articles.count).to be > 120
-
-            travel(14.minutes)
-
-            expect { create(:twitter_channel).fetch }
-              .not_to change(Ticket::Article, :count)
-
-            travel(1.minute)
-
-            expect { create(:twitter_channel).fetch }
-              .to change(Ticket::Article, :count)
-          end
-        end
-      end
-    end
   end
 end

+ 1 - 2
spec/models/ticket/article_spec.rb

@@ -374,7 +374,7 @@ RSpec.describe Ticket::Article, type: :model do
       it 'sets #from to sender’s Twitter handle' do
         expect { perform_enqueued_jobs }
           .to change { twitter_article.reload.from }
-          .to('@ZammadTesting')
+          .to('@APITesting001')
       end
 
       it 'sets #to to recipient’s Twitter handle' do
@@ -386,7 +386,6 @@ RSpec.describe Ticket::Article, type: :model do
       it 'sets #message_id to tweet ID (https://twitter.com/_/status/<id>)' do
         expect { perform_enqueued_jobs }
           .to change { twitter_article.reload.message_id }
-          .to('1410130368498372609')
       end
 
       it 'sets #preferences with tweet metadata' do

+ 507 - 0
spec/requests/external_credential/twitter_spec.rb

@@ -0,0 +1,507 @@
+# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+require 'rails_helper'
+
+RSpec.describe 'External Credentials > Twitter', required_envs: %w[TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_OAUTH_TOKEN TWITTER_OAUTH_TOKEN_SECRET TWITTER_DEV_ENVIRONMENT], type: :request do
+  let(:admin) { create(:admin) }
+
+  let(:valid_credentials)   { attributes_for(:twitter_credential)[:credentials] }
+  let(:invalid_credentials) { attributes_for(:twitter_credential, :invalid)[:credentials] }
+
+  let(:webhook_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/channels_twitter_webhook" }
+
+  def body_forbidden
+    {
+      errors: [
+        {
+          code:    403,
+          message: 'Forbidden.',
+        },
+      ],
+    }.to_json
+  end
+
+  def headers
+    { content_type: 'application/json; charset=utf-8' }
+  end
+
+  def oauth_request_token
+    stub_post('https://api.twitter.com/oauth/request_token').to_return(
+      status: 200,
+      body:   'oauth_token=DY8E9gAAAAABCFc9AAABcP4JGzI&oauth_token_secret=gAR1aD2RGw3klpbxNtMuwvALohChdLDR&oauth_callback_confirmed=true',
+    )
+  end
+
+  def oauth_request_token_unauthorized
+    stub_post('https://api.twitter.com/oauth/request_token').to_return(
+      status: [ 401, 'Unauthorized' ],
+      body:   '',
+    )
+  end
+
+  def oauth_request_token_forbidden
+    stub_post('https://api.twitter.com/oauth/request_token').to_return(
+      status: [ 403, 'Forbidden' ],
+      body:   '',
+    )
+  end
+
+  def oauth_access_token
+    stub_post('https://api.twitter.com/oauth/access_token').to_return(
+      body: 'oauth_token=DY8E9gAAAAABCFc9AAABcP4JGzI&oauth_token_secret=15DOeRkjP4JkOSVqULkTKA1SCuIPP105&user_id=1408314039470538752&screen_name=APITesting001'
+    )
+  end
+
+  def webhook_data(app, valid)
+    {
+      id:         '1234567890',
+      url:        "https://#{app}.example.com/api/v1/channels_twitter_webhook",
+      valid:      valid,
+      created_at: '2022-10-11T07:30:00Z',
+    }
+  end
+
+  def webhooks_forbidden
+    stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
+      status:  403,
+      body:    body_forbidden,
+      headers: headers,
+    )
+  end
+
+  def webhooks_ok
+    stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
+      status:  200,
+      body:    {
+        environments: [
+          environment_name: 'Integration',
+          webhooks:         [ webhook_data('zammad', true) ],
+        ],
+      }.to_json,
+      headers: headers,
+    )
+  end
+
+  def webhooks_env_empty(env: 'zammad')
+    stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
+      status:  200,
+      body:    [].to_json,
+      headers: headers,
+    )
+  end
+
+  def webhooks_env_forbidden(env: 'zammad')
+    stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
+      status:  403,
+      body:    body_forbidden,
+      headers: headers,
+    )
+  end
+
+  def webhooks_env_another_app
+    webhooks_env_ok(valid: true, app: 'another-app')
+  end
+
+  def webhooks_env_invalid
+    webhooks_env_ok(valid: false)
+  end
+
+  def webhooks_env_ok(valid: true, app: 'zammad')
+    stub_get('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
+      status:  200,
+      body:    [ webhook_data(app, valid) ].to_json,
+      headers: headers,
+    )
+  end
+
+  def register_webhook
+    stub_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
+      status:  200,
+      body:    webhook_data('zammad', true).to_json,
+      headers: headers,
+    )
+  end
+
+  def delete_webhook
+    stub_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
+      status: 204,
+      body:   nil,
+    )
+  end
+
+  def crc_webhook
+    stub_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
+      status: 204,
+      body:   nil,
+    )
+  end
+
+  def account_verify_credentials
+    stub_get('https://api.twitter.com/1.1/account/verify_credentials.json').to_return(
+      body:    Rails.root.join('spec/fixtures/files/external_credentials/twitter/zammad_testing.json').read,
+      headers: { content_type: 'application/json; charset=utf-8' },
+    )
+  end
+
+  def env_subscriptions(env: 'zammad')
+    stub_post("https://api.twitter.com/1.1/account_activity/all/#{env}/subscriptions.json").to_return(
+      status:  204,
+      headers: { content_type: 'application/json; charset=utf-8' },
+    )
+  end
+
+  describe 'POST /api/v1/external_credentials/twitter/app_verify' do
+    before do
+      authenticated_as(admin, via: :browser)
+    end
+
+    context 'when permission for Twitter channel is deactivated' do
+      before do
+        Permission.find_by(name: 'admin.channel_twitter').update(active: false)
+      end
+
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
+
+        expect(response).to have_http_status(:forbidden)
+        expect(json_response).to include('error' => 'Not authorized (user)!')
+      end
+    end
+
+    context 'with no credentials' do
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
+
+        expect(response).to have_http_status(:ok)
+        expect(json_response).to include('error' => "The required parameter 'consumer_key' is missing.")
+      end
+    end
+
+    context 'with invalid credential params' do
+      before do
+        oauth_request_token_unauthorized
+      end
+
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: invalid_credentials, as: :json
+
+        expect(response).to have_http_status(:ok)
+        expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
+      end
+    end
+
+    context 'with valid credential params but misconfigured callback URL' do
+      before do
+        oauth_request_token_forbidden
+      end
+
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+        expect(response).to have_http_status(:ok)
+        expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
+      end
+    end
+
+    context 'with valid credential params and callback URL but no dev env registered' do
+      before do
+        oauth_request_token
+        webhooks_forbidden
+        webhooks_env_forbidden
+      end
+
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+        expect(response).to have_http_status(:ok)
+        expect(json_response).to include('error' => 'Forbidden. Are you sure you created a development environment on developer.twitter.com?')
+      end
+    end
+
+    context 'with valid credential params and callback URL but wrong dev env label' do
+      before do
+        oauth_request_token
+        webhooks_ok
+        webhooks_env_forbidden(env: 'foo')
+      end
+
+      it 'blocks the request', :aggregate_failures do
+        post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials.merge(env: 'foo'), as: :json
+
+        expect(response).to have_http_status(:ok)
+        expect(json_response).to include('error' => "Dev Environment Label invalid. Please use an existing one [\"#{ENV.fetch('TWITTER_DEV_ENVIRONMENT', 'Integration')}\"], or create a new one.")
+      end
+    end
+
+    context 'with valid credential params, callback URL, and dev env label' do
+      before do
+        oauth_request_token
+
+        Setting.set('http_type', 'https')
+        Setting.set('fqdn', 'zammad.example.com')
+      end
+
+      context 'with no existing webhooks' do
+        before do
+          webhooks_env_empty
+          register_webhook
+        end
+
+        it 'registers a new webhook', :aggregate_failures do
+          post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+          expect(a_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').with(body: "url=#{CGI.escape(webhook_url)}")).to have_been_made.once
+
+          expect(response).to have_http_status(:ok)
+          expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
+        end
+      end
+
+      context 'with an existing webhook registered to another app' do
+        before do
+          webhooks_env_another_app
+          delete_webhook
+          register_webhook
+        end
+
+        it 'deletes all existing webhooks and registers a new one', :aggregate_failures do
+          post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+          expect(a_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json'))
+            .to have_been_made.once
+
+          expect(response).to have_http_status(:ok)
+          expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
+        end
+      end
+
+      context 'with an existing, invalid webhook registered to Zammad' do
+        before do
+          webhooks_env_invalid
+          crc_webhook
+        end
+
+        it 'revalidates by manually triggering a challenge-response check', :aggregate_failures do
+          post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+          expect(a_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json')).to have_been_made.once
+
+          expect(response).to have_http_status(:ok)
+          expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
+        end
+      end
+
+      context 'with an existing, valid webhook registered to Zammad' do
+        before do
+          webhooks_env_ok
+        end
+
+        it 'uses the existing webhook' do
+          post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
+
+          expect(a_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').with(body: "url=#{CGI.escape(webhook_url)}")).not_to have_been_made
+        end
+      end
+    end
+  end
+
+  describe 'GET /api/v1/external_credentials/twitter/link_account' do
+    before do
+      authenticated_as(admin, via: :browser)
+    end
+
+    context 'with no Twitter app' do
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(response).to have_http_status(:unprocessable_entity)
+        expect(json_response).to include('error' => 'There is no Twitter app configured.')
+      end
+    end
+
+    context 'with invalid Twitter app (configured with invalid credentials)' do
+      before do
+        create(:twitter_credential, :invalid)
+        oauth_request_token_unauthorized
+      end
+
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(response).to have_http_status(:internal_server_error)
+        expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
+      end
+    end
+
+    context 'with a valid Twitter app but misconfigured callback URL' do
+      before do
+        create(:twitter_credential)
+        oauth_request_token_forbidden
+      end
+
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(response).to have_http_status(:internal_server_error)
+        expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
+      end
+    end
+
+    context 'with a valid Twitter app and callback URL' do
+      let(:twitter_credential) { create(:twitter_credential) }
+
+      before do
+        twitter_credential
+        oauth_request_token
+
+        Setting.set('http_type', 'https')
+        Setting.set('fqdn', 'zammad.example.com')
+      end
+
+      it 'returns authorization data in the headers' do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(
+          a_post('https://api.twitter.com/oauth/request_token').with(headers: { 'Authorization' => %r{oauth_consumer_key="#{twitter_credential.credentials[:consumer_key]}"} })
+        ).to have_been_made.once
+      end
+
+      it 'redirects to Twitter authorization URL' do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(response).to redirect_to(%r{^https://api.twitter.com/oauth/authorize\?oauth_token=\w+$})
+      end
+
+      it 'saves request token to session hash' do
+        get '/api/v1/external_credentials/twitter/link_account', as: :json
+
+        expect(session[:request_token]).to be_a(OAuth::RequestToken)
+      end
+    end
+  end
+
+  describe 'GET /api/v1/external_credentials/twitter/callback' do
+    before do
+      authenticated_as(admin, via: :browser)
+    end
+
+    context 'with no Twitter app' do
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/callback', as: :json
+
+        expect(response).to have_http_status(:unprocessable_entity)
+        expect(json_response).to include('error' => 'There is no Twitter app configured.')
+      end
+    end
+
+    context 'with valid Twitter app but no request token' do
+      before do
+        create(:twitter_credential)
+      end
+
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/callback', as: :json
+
+        expect(response).to have_http_status(:unprocessable_entity)
+        expect(json_response).to include('error' => "The required parameter 'request_token' is missing.")
+      end
+    end
+
+    context 'with valid Twitter app and request token but non-matching OAuth token (via params)' do
+      before do
+        create(:twitter_credential)
+
+        Setting.set('http_type', 'https')
+        Setting.set('fqdn', 'zammad.example.com')
+
+        oauth_request_token
+        get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
+      end
+
+      it 'returns an error message', :aggregate_failures do
+        get '/api/v1/external_credentials/twitter/callback', as: :json
+
+        expect(response).to have_http_status(:unprocessable_entity)
+        expect(json_response).to include('error' => "The provided 'oauth_token' is invalid.")
+      end
+    end
+
+    context 'with valid Twitter app, request token, and matching OAuth token (via params)' do
+      let(:twitter_credential) { create(:twitter_credential) }
+      let(:params)             { { oauth_token: 'DY8E9gAAAAABCFc9AAABcP4JGzI', oauth_verifier: '15DOeRkjP4JkOSVqULkTKA1SCuIPP105' } }
+
+      before do
+        twitter_credential
+        Setting.set('http_type', 'https')
+        Setting.set('fqdn', 'zammad.example.com')
+
+        oauth_request_token
+        get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
+
+        oauth_access_token
+        account_verify_credentials
+        env_subscriptions
+      end
+
+      it 'creates a new channel', :aggregate_failures do
+        expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
+
+        expect(Channel.last.options).to include('adapter' => 'twitter')
+          .and include('user' => hash_including('id', 'screen_name', 'name'))
+          .and include('auth' => hash_including('external_credential_id', 'oauth_token', 'oauth_token_secret'))
+      end
+
+      it 'redirects to the newly created channel', :aggregate_failures do
+        expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
+
+        expect(response).to redirect_to(%r{/#channels/twitter/#{Channel.last.id}$})
+      end
+
+      it 'clears the :request_token session variable', :aggregate_failures do
+        expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
+
+        expect(session[:request_token]).to be_nil
+      end
+
+      it 'subscribes to webhooks', :aggregate_failures do
+        expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
+
+        expect(
+          a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
+        ).to have_been_made.once
+
+        expect(Channel.last.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
+      end
+
+      context 'when Twitter account has already been added' do
+        let(:channel) { create(:twitter_channel) }
+
+        before do
+          channel
+        end
+
+        it 'uses the existing channel' do
+          expect do
+            get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
+          end.not_to change(Channel, :count)
+        end
+
+        it 'updates channel properties' do
+          expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change { channel.reload.updated_at }
+            .and change { channel.reload.options[:auth][:external_credential_id] }
+            .and change { channel.reload.options[:auth][:oauth_token] }
+            .and change { channel.reload.options[:auth][:oauth_token_secret] }
+        end
+
+        it 'subscribes to webhooks', :aggregate_failures do
+          get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
+
+          expect(
+            a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
+          ).to have_been_made.once
+          expect(channel.reload.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
+        end
+      end
+    end
+  end
+end

Some files were not shown because too many files changed in this diff