Browse Source

Refactoring: Add coverage for TwitterSync#process_webhook

Prior to this commit,
the TwitterSync#process_webhook method had only partial test coverage
(in `spec/requests/integration/twitter_webhook_spec.rb`).
This commit replaces the existing, incomplete tests
with a direct spec of the TwitterSync class,
giving individual test cases for all reasonable edge cases
of the #process_webhook method.

Since this method is responsible for a lot of complex behavior,
there are a _lot_ of such test cases.
Putting them all in the same spec file makes it hard to read through,
so a future goal is to break them up into smaller methods
and move some of these methods into other, more appropriate classes.

This could not be achieved with a request spec,
since they enforce testing at a single interface /
level of abstraction (i.e., HTTP requests).

=== Note: This commit will not pass CI.

Some bugs in the existing code were uncovered
in the process of adding comprehensive test coverage
for the #process_webhook method.

In the interest of isolating changes into discrete chunks, fixes for
these bugs have been reserved for the next commit.
Ryan Lue 5 years ago
parent
commit
64c882b090

+ 661 - 0
spec/lib/twitter_sync_spec.rb

@@ -1,6 +1,10 @@
 require 'rails_helper'
 
 RSpec.describe TwitterSync do
+  subject(:twitter_sync) { described_class.new(channel.options[:auth], payload) }
+
+  let(:channel) { create(:twitter_channel) }
+
   describe '.preferences_cleanup' do
     shared_examples 'for normalizing input' do
       it 'is converted (from bare hash)' do
@@ -65,4 +69,661 @@ RSpec.describe TwitterSync do
       include_examples 'for normalizing input'
     end
   end
+
+  describe '#process_webhook' do
+    before do
+      # TODO: This is necessary to implicitly set #created_by_id and #updated_by_id on new records.
+      # It is usually performed by the ApplicationController::HasUser#set_user filter,
+      # but since we are testing this class in isolation of the controller,
+      # it has to be done manually here.
+      #
+      # Consider putting this in the method itself, rather than in a spec `before` hook.
+      UserInfo.current_user_id = 1
+
+      # Twitter channels must be configured to know whose account they're monitoring.
+      channel.options[:user][:id] = payload[:for_user_id]
+      channel.save!
+    end
+
+    let(:payload) { YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]) }
+
+    # TODO: This aspect of #process_webhook's behavior involves deep interaction
+    # with the User, Avatar, and Authorization classes,
+    # and thus should really be refactored (moved) elsewhere.
+    # These specs are being written to support such a refactoring,
+    # and should be migrated as appropriate when the logic is eventually relocated.
+    shared_examples 'for user processing' do
+      let(:sender_attributes) do
+        {
+          'login'        => sender_profile[:screen_name],
+          'firstname'    => sender_profile[:name].capitalize,
+          'web'          => sender_profile[:url],
+          'note'         => sender_profile[:description],
+          'address'      => sender_profile[:location],
+          'image_source' => sender_profile[:profile_image_url],
+        }
+      end
+
+      let(:avatar_attributes) do
+        {
+          'object_lookup_id' => ObjectLookup.by_name('User'),
+          'deletable'        => true,
+          'source'           => 'twitter',
+          'source_url'       => sender_profile[:profile_image_url],
+        }
+      end
+
+      let(:authorization_attributes) do
+        {
+          'uid'      => sender_profile[:id],
+          'username' => sender_profile[:screen_name],
+          'provider' => 'twitter',
+        }
+      end
+
+      context 'from unknown user' do
+        it 'creates a User record for the sender' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(User, :count).by(1)
+            .and change { User.exists?(sender_attributes) }.to(true)
+        end
+
+        it 'creates an Avatar record for the sender', :use_vcr do
+          # Why 2, and not 1? Avatar.add auto-generates a default (source: 'init') record
+          # before actually adding the specified (source: 'twitter') one.
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Avatar, :count).by_at_least(1)
+            .and change { Avatar.exists?(avatar_attributes) }.to(true)
+
+          expect(User.last.image).to eq(Avatar.last.store_hash)
+        end
+
+        it 'creates an Authorization record for the sender' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Authorization, :count).by(1)
+            .and change { Authorization.exists?(authorization_attributes) }.to(true)
+        end
+      end
+
+      context 'from known user' do
+        let!(:user) { create(:user) }
+
+        let!(:avatar) { create(:avatar, o_id: user.id, object_lookup_id: ObjectLookup.by_name('User'), source: 'twitter') }
+
+        let!(:authorization) do
+          Authorization.create(user_id: user.id, uid: sender_profile[:id], provider: 'twitter')
+        end
+
+        it 'updates the sender’s existing User record' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to not_change(User, :count)
+            .and not_change { user.reload.attributes.slice('login', 'firstname') }
+            .and change { User.exists?(sender_attributes.except('login', 'firstname')) }.to(true)
+        end
+
+        it 'updates the sender’s existing Avatar record', :use_vcr do
+          expect { twitter_sync.process_webhook(channel) }
+            .to not_change(Avatar, :count)
+            .and change { Avatar.exists?(avatar_attributes) }.to(true)
+
+          expect(user.reload.image).to eq(avatar.reload.store_hash)
+        end
+
+        it 'updates the sender’s existing Authorization record' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to not_change(Authorization, :count)
+            .and change { Authorization.exists?(authorization_attributes) }.to(true)
+        end
+      end
+    end
+
+    context 'for incoming DM' do
+      let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming.yml') }
+
+      include_examples 'for user processing' do
+        # Payload sent by Twitter is { ..., users: [{ <uid>: <sender> }, { <uid>: <receiver> }] }
+        let(:sender_profile) { payload[:users].values.first }
+      end
+
+      describe 'ticket creation' do
+        let(:ticket_attributes) do
+          # NOTE: missing "customer_id" (because the value is generated as part of the #process_webhook method)
+          {
+            'title'       => title,
+            'group_id'    => channel.options[:sync][:direct_messages][:group_id],
+            'state'       => Ticket::State.find_by(default_create: true),
+            'priority'    => Ticket::Priority.find_by(default_create: true),
+            'preferences' => {
+              'channel_id'          => channel.id,
+              'channel_screen_name' => channel.options[:user][:screen_name],
+            },
+          }
+        end
+
+        let(:title) { payload[:direct_message_events].first[:message_create][:message_data][:text] }
+
+        it 'creates a new ticket' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket, :count).by(1)
+            .and change { Ticket.exists?(ticket_attributes) }.to(true)
+        end
+
+        context 'for duplicate messages' do
+          before do
+            described_class.new(
+              channel.options[:auth],
+              YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
+            ).process_webhook(channel)
+          end
+
+          it 'does not create duplicate ticket' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to not_change(Ticket, :count)
+              .and not_change(Ticket::Article, :count)
+          end
+        end
+
+        context 'for message longer than 80 chars' do
+          before { payload[:direct_message_events].first[:message_create][:message_data][:text] = 'a' * 81 }
+
+          let(:title) { "#{'a' * 80}..." }
+
+          it 'creates ticket with truncated title' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change(Ticket, :count).by(1)
+              .and change { Ticket.exists?(ticket_attributes) }.to(true)
+          end
+        end
+
+        context 'in reply to existing thread/ticket' do
+          # import parent DM
+          before do
+            described_class.new(
+              channel.options[:auth],
+              YAML.safe_load(
+                File.read(Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming.yml')),
+                [ActiveSupport::HashWithIndifferentAccess]
+              )
+            ).process_webhook(channel)
+          end
+
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_2.yml') }
+
+          it 'uses existing ticket' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to not_change(Ticket, :count)
+              .and not_change { Ticket.last.state }
+          end
+
+          context 'marked "closed" / "merged" / "removed"' do
+            before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) }
+
+            it 'creates a new ticket' do
+              expect { twitter_sync.process_webhook(channel) }
+                .to change(Ticket, :count).by(1)
+                .and change { Ticket.exists?(ticket_attributes) }.to(true)
+            end
+          end
+
+          context 'marked "pending reminder" / "pending close"' do
+            before { Ticket.last.update(state: Ticket::State.find_by(name: 'pending reminder')) }
+
+            it 'sets existing ticket to "open"' do
+              expect { twitter_sync.process_webhook(channel) }
+                .to not_change(Ticket, :count)
+                .and change { Ticket.last.state.name }.to('open')
+            end
+          end
+        end
+      end
+
+      describe 'article creation' do
+        let(:article_attributes) do
+          # NOTE: missing "ticket_id" (because the value is generated as part of the #process_webhook method)
+          {
+            'from'        => "@#{payload[:users].values.first[:screen_name]}",
+            'to'          => "@#{payload[:users].values.second[:screen_name]}",
+            'body'        => payload[:direct_message_events].first[:message_create][:message_data][:text],
+            'message_id'  => payload[:direct_message_events].first[:id],
+            'in_reply_to' => nil,
+            'type_id'     => Ticket::Article::Type.find_by(name: 'twitter direct-message').id,
+            'sender_id'   => Ticket::Article::Sender.find_by(name: 'Customer').id,
+            'internal'    => false,
+            'preferences' => { 'twitter' => twitter_prefs, 'links' => link_array }
+          }
+        end
+
+        let(:twitter_prefs) do
+          {
+            'created_at'            => payload[:direct_message_events].first[:created_timestamp],
+            'recipient_id'          => payload[:direct_message_events].first[:message_create][:target][:recipient_id],
+            'recipient_screen_name' => payload[:users].values.second[:screen_name],
+            'sender_id'             => payload[:direct_message_events].first[:message_create][:sender_id],
+            'sender_screen_name'    => payload[:users].values.first[:screen_name],
+            'app_id'                => payload[:apps]&.values&.first&.dig(:app_id),
+            'app_name'              => payload[:apps]&.values&.first&.dig(:app_name),
+            'geo'                   => {},
+            'place'                 => {},
+          }
+        end
+
+        let(:link_array) do
+          [
+            {
+              'url'    => "https://twitter.com/messages/#{user_ids.map(&:to_i).sort.join('-')}",
+              'target' => '_blank',
+              'name'   => 'on Twitter',
+            },
+          ]
+        end
+
+        let(:user_ids) { payload[:users].values.map { |u| u[:id] } }
+
+        it 'creates a new article' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket::Article, :count).by(1)
+            .and change { Ticket::Article.exists?(article_attributes) }.to(true)
+        end
+
+        context 'for duplicate messages' do
+          before do
+            described_class.new(
+              channel.options[:auth],
+              YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
+            ).process_webhook(channel)
+          end
+
+          it 'does not create duplicate article' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to not_change(Ticket::Article, :count)
+          end
+        end
+
+        context 'when message contains shortened (t.co) url' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_with_url.yml') }
+
+          it 'replaces the t.co url for the original' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
+                Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition
+              BODY
+          end
+        end
+      end
+    end
+
+    context 'for outgoing DM' do
+      let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-outgoing.yml') }
+
+      describe 'ticket creation' do
+        let(:ticket_attributes) do
+          # NOTE: missing "customer_id" (because User.last changes before and after the method is called)
+          {
+            'title'       => payload[:direct_message_events].first[:message_create][:message_data][:text],
+            'group_id'    => channel.options[:sync][:direct_messages][:group_id],
+            'state'       => Ticket::State.find_by(name: 'closed'),
+            'priority'    => Ticket::Priority.find_by(default_create: true),
+            'preferences' => {
+              'channel_id'          => channel.id,
+              'channel_screen_name' => channel.options[:user][:screen_name],
+            },
+          }
+        end
+
+        it 'creates a closed ticket' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket, :count).by(1)
+            .and change { Ticket.exists?(ticket_attributes) }.to(true)
+        end
+      end
+
+      describe 'article creation' do
+        let(:article_attributes) do
+          # NOTE: missing "ticket_id" (because the value is generated as part of the #process_webhook method)
+          {
+            'from'        => "@#{payload[:users].values.first[:screen_name]}",
+            'to'          => "@#{payload[:users].values.second[:screen_name]}",
+            'body'        => payload[:direct_message_events].first[:message_create][:message_data][:text],
+            'message_id'  => payload[:direct_message_events].first[:id],
+            'in_reply_to' => nil,
+            'type_id'     => Ticket::Article::Type.find_by(name: 'twitter direct-message').id,
+            'sender_id'   => Ticket::Article::Sender.find_by(name: 'Customer').id,
+            'internal'    => false,
+            'preferences' => { 'twitter' => twitter_prefs, 'links' => link_array }
+          }
+        end
+
+        let(:twitter_prefs) do
+          {
+            'created_at'            => payload[:direct_message_events].first[:created_timestamp],
+            'recipient_id'          => payload[:direct_message_events].first[:message_create][:target][:recipient_id],
+            'recipient_screen_name' => payload[:users].values.second[:screen_name],
+            'sender_id'             => payload[:direct_message_events].first[:message_create][:sender_id],
+            'sender_screen_name'    => payload[:users].values.first[:screen_name],
+            'app_id'                => payload[:apps]&.values&.first&.dig(:app_id),
+            'app_name'              => payload[:apps]&.values&.first&.dig(:app_name),
+            'geo'                   => {},
+            'place'                 => {},
+          }
+        end
+
+        let(:link_array) do
+          [
+            {
+              'url'    => "https://twitter.com/messages/#{user_ids.map(&:to_i).sort.join('-')}",
+              'target' => '_blank',
+              'name'   => 'on Twitter',
+            },
+          ]
+        end
+
+        let(:user_ids) { payload[:users].values.map { |u| u[:id] } }
+
+        it 'creates a new article' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket::Article, :count).by(1)
+            .and change { Ticket::Article.exists?(article_attributes) }.to(true)
+        end
+
+        context 'when message contains shortened (t.co) url' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_with_url.yml') }
+
+          it 'replaces the t.co url for the original' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
+                Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition
+              BODY
+          end
+        end
+
+        context 'when message contains a media attachment (e.g., JPG)' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_with_media.yml') }
+
+          it 'does not store it as an attachment on the article' do
+            twitter_sync.process_webhook(channel)
+
+            expect(Ticket::Article.last.attachments).to be_empty
+          end
+        end
+      end
+    end
+
+    context 'for incoming tweet' do
+      let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention.yml') }
+
+      include_examples 'for user processing' do
+        # Payload sent by Twitter is { ..., tweet_create_events: [{ ..., user: <author> }] }
+        let(:sender_profile) { payload[:tweet_create_events].first[:user] }
+      end
+
+      describe 'ticket creation' do
+        let(:ticket_attributes) do
+          # NOTE: missing "customer_id" (because User.last changes before and after the method is called)
+          {
+            'title'       => payload[:tweet_create_events].first[:text],
+            'group_id'    => channel.options[:sync][:direct_messages][:group_id],
+            'state'       => Ticket::State.find_by(default_create: true),
+            'priority'    => Ticket::Priority.find_by(default_create: true),
+            'preferences' => {
+              'channel_id'          => channel.id,
+              'channel_screen_name' => channel.options[:user][:screen_name],
+            },
+          }
+        end
+
+        it 'creates a new ticket' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket, :count).by(1)
+        end
+
+        context 'for duplicate tweets' do
+          before do
+            described_class.new(
+              channel.options[:auth],
+              YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
+            ).process_webhook(channel)
+          end
+
+          it 'does not create duplicate ticket' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to not_change(Ticket, :count)
+              .and not_change(Ticket::Article, :count)
+          end
+        end
+
+        context 'in response to existing tweet thread' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-response.yml') }
+
+          let(:parent_tweet_payload) do
+            YAML.safe_load(
+              File.read(Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention.yml')),
+              [ActiveSupport::HashWithIndifferentAccess]
+            )
+          end
+
+          context 'that hasn’t been imported yet', :use_vcr do
+            it 'creates a new ticket' do
+              expect { twitter_sync.process_webhook(channel) }
+                .to change(Ticket, :count).by(1)
+            end
+
+            it 'retrieves the parent tweet via the Twitter API' do
+              expect { twitter_sync.process_webhook(channel) }
+                .to change(Ticket::Article, :count).by(2)
+
+              expect(Ticket::Article.second_to_last.body).to eq(parent_tweet_payload[:tweet_create_events].first[:text])
+            end
+
+            context 'after parent tweet has been deleted' do
+              before do
+                payload[:tweet_create_events].first[:in_reply_to_status_id] = 1207610954160037890 # rubocop:disable Style/NumericLiterals
+                payload[:tweet_create_events].first[:in_reply_to_status_id_str] = '1207610954160037890'
+              end
+
+              it 'creates a new ticket' do
+                expect { twitter_sync.process_webhook(channel) }
+                  .to change(Ticket, :count).by(1)
+              end
+
+              it 'silently ignores error when retrieving parent tweet' do
+                expect { twitter_sync.process_webhook(channel) }.to not_raise_error
+              end
+            end
+          end
+
+          context 'that was previously imported' do
+            # import parent tweet
+            before { described_class.new(channel.options[:auth], parent_tweet_payload).process_webhook(channel) }
+
+            it 'uses existing ticket' do
+              expect { twitter_sync.process_webhook(channel) }
+                .to not_change(Ticket, :count)
+                .and not_change { Ticket.last.state }
+            end
+
+            context 'and marked "closed" / "merged" / "removed" / "pending reminder" / "pending close"' do
+              before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) }
+
+              it 'sets existing ticket to "open"' do
+                expect { twitter_sync.process_webhook(channel) }
+                  .to not_change(Ticket, :count)
+                  .and change { Ticket.last.state.name }.to('open')
+              end
+            end
+          end
+        end
+      end
+
+      describe 'article creation' do
+        let(:article_attributes) do
+          # NOTE: missing "ticket_id" (because the value is generated as part of the #process_webhook method)
+          {
+            'from'        => "@#{payload[:tweet_create_events].first[:user][:screen_name]}",
+            'to'          => "@#{payload[:tweet_create_events].first[:entities][:user_mentions].first[:screen_name]}",
+            'body'        => payload[:tweet_create_events].first[:text],
+            'message_id'  => payload[:tweet_create_events].first[:id_str],
+            'in_reply_to' => payload[:tweet_create_events].first[:in_reply_to_status_id],
+            'type_id'     => Ticket::Article::Type.find_by(name: 'twitter status').id,
+            'sender_id'   => Ticket::Article::Sender.find_by(name: 'Customer').id,
+            'internal'    => false,
+            'preferences' => { 'twitter' => twitter_prefs, 'links' => link_array }
+          }
+        end
+
+        let(:twitter_prefs) do
+          {
+            'mention_ids'         => payload[:tweet_create_events].first[:entities][:user_mentions].map { |um| um[:id] },
+            'geo'                 => payload[:tweet_create_events].first[:geo].to_h,
+            'retweeted'           => payload[:tweet_create_events].first[:retweeted],
+            'possibly_sensitive'  => payload[:tweet_create_events].first[:possibly_sensitive],
+            'in_reply_to_user_id' => payload[:tweet_create_events].first[:in_reply_to_user_id],
+            'place'               => payload[:tweet_create_events].first[:place].to_h,
+            'retweet_count'       => payload[:tweet_create_events].first[:retweet_count],
+            'source'              => payload[:tweet_create_events].first[:source],
+            'favorited'           => payload[:tweet_create_events].first[:favorited],
+            'truncated'           => payload[:tweet_create_events].first[:truncated],
+          }
+        end
+
+        let(:link_array) do
+          [
+            {
+              'url'    => "https://twitter.com/_/status/#{payload[:tweet_create_events].first[:id]}",
+              'target' => '_blank',
+              'name'   => 'on Twitter',
+            },
+          ]
+        end
+
+        it 'creates a new article' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket::Article, :count).by(1)
+            .and change { Ticket::Article.exists?(article_attributes) }.to(true)
+        end
+
+        context 'when message mentions multiple users' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_multiple.yml') }
+
+          let(:mentionees) { "@#{payload[:tweet_create_events].first[:entities][:user_mentions].map { |um| um[:screen_name] }.join(', @')}" }
+
+          it 'records all mentionees in comma-separated "to" attribute' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(to: mentionees) }.to(true)
+          end
+        end
+
+        context 'when message exceeds 140 characters' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_extended.yml') }
+
+          let(:full_body) { payload[:tweet_create_events].first[:extended_tweet][:full_text] }
+
+          it 'records the full (extended) tweet body' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(body: full_body) }.to(true)
+          end
+        end
+
+        context 'when message contains shortened (t.co) url' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_with_url.yml') }
+
+          it 'replaces the t.co url for the original' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
+                @ScruffyMcG https://zammad.org/
+              BODY
+          end
+        end
+
+        context 'when message contains a media attachment (e.g., JPG)' do
+          let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_with_media.yml') }
+
+          it 'replaces the t.co url for the original' do
+            expect { twitter_sync.process_webhook(channel) }
+              .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
+                @ScruffyMcG https://twitter.com/pennbrooke1/status/1209101446706122752/photo/1
+              BODY
+          end
+
+          it 'stores it as an attachment on the article', :use_vcr do
+            twitter_sync.process_webhook(channel)
+
+            expect(Ticket::Article.last.attachments).to be_one
+          end
+        end
+      end
+    end
+
+    context 'for outgoing tweet' do
+      let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_outgoing.yml') }
+
+      describe 'ticket creation' do
+        let(:ticket_attributes) do
+          # NOTE: missing "customer_id" (because User.last changes before and after the method is called)
+          {
+            'title'       => payload[:tweet_create_events].first[:text],
+            'group_id'    => channel.options[:sync][:direct_messages][:group_id],
+            'state'       => Ticket::State.find_by(name: 'closed'),
+            'priority'    => Ticket::Priority.find_by(default_create: true),
+            'preferences' => {
+              'channel_id'          => channel.id,
+              'channel_screen_name' => channel.options[:user][:screen_name],
+            },
+          }
+        end
+
+        it 'creates a closed ticket' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket, :count).by(1)
+        end
+      end
+
+      describe 'article creation' do
+        let(:article_attributes) do
+          # NOTE: missing "ticket_id" (because the value is generated as part of the #process_webhook method)
+          {
+            'from'        => "@#{payload[:tweet_create_events].first[:user][:screen_name]}",
+            'to'          => "@#{payload[:tweet_create_events].first[:entities][:user_mentions].first[:screen_name]}",
+            'body'        => payload[:tweet_create_events].first[:text],
+            'message_id'  => payload[:tweet_create_events].first[:id_str],
+            'in_reply_to' => payload[:tweet_create_events].first[:in_reply_to_status_id],
+            'type_id'     => Ticket::Article::Type.find_by(name: 'twitter status').id,
+            'sender_id'   => Ticket::Article::Sender.find_by(name: 'Customer').id,
+            'internal'    => false,
+            'preferences' => { 'twitter' => twitter_prefs, 'links' => link_array }
+          }
+        end
+
+        let(:twitter_prefs) do
+          {
+            'mention_ids'         => payload[:tweet_create_events].first[:entities][:user_mentions].map { |um| um[:id] },
+            'geo'                 => payload[:tweet_create_events].first[:geo].to_h,
+            'retweeted'           => payload[:tweet_create_events].first[:retweeted],
+            'possibly_sensitive'  => payload[:tweet_create_events].first[:possibly_sensitive],
+            'in_reply_to_user_id' => payload[:tweet_create_events].first[:in_reply_to_user_id],
+            'place'               => payload[:tweet_create_events].first[:place].to_h,
+            'retweet_count'       => payload[:tweet_create_events].first[:retweet_count],
+            'source'              => payload[:tweet_create_events].first[:source],
+            'favorited'           => payload[:tweet_create_events].first[:favorited],
+            'truncated'           => payload[:tweet_create_events].first[:truncated],
+          }
+        end
+
+        let(:link_array) do
+          [
+            {
+              'url'    => "https://twitter.com/_/status/#{payload[:tweet_create_events].first[:id]}",
+              'target' => '_blank',
+              'name'   => 'on Twitter',
+            },
+          ]
+        end
+
+        it 'creates a new article' do
+          expect { twitter_sync.process_webhook(channel) }
+            .to change(Ticket::Article, :count).by(1)
+            .and change { Ticket::Article.exists?(article_attributes) }.to(true)
+        end
+      end
+    end
+  end
 end

+ 0 - 312
spec/requests/integration/twitter_webhook_spec.rb

@@ -1,312 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe 'Twitter Webhook Integration', type: :request do
-  let!(:external_credential) { create(:twitter_credential, credentials: credentials) }
-  let(:credentials) { { consumer_key: 'CCC', consumer_secret: 'DDD' } }
-
-  describe '#webhook_incoming' do
-    let!(:channel) do
-      create(
-        :twitter_channel,
-        custom_options: {
-          auth: {
-            external_credential_id: external_credential.id,
-            oauth_token:            'AAA',
-            oauth_token_secret:     'BBB',
-            consumer_key:           'CCC',
-            consumer_secret:        'DDD',
-          },
-          user: {
-            id:          123,
-            name:        'Zammad HQ',
-            screen_name: 'zammadhq',
-          },
-          sync: {
-            limit:          20,
-            track_retweets: false,
-            search:         [
-              {
-                term: '#zammad', group_id: Group.first.id.to_s
-              },
-              {
-                term: '#hello1234', group_id: Group.first.id.to_s
-              }
-            ],
-          }
-        }
-      )
-    end
-
-    describe 'auto-creation of tickets/articles on webhook receipt' do
-      let(:webhook_payload) do
-        JSON.parse(File.read(Rails.root.join('test/data/twitter', payload_file))).symbolize_keys
-      end
-
-      context 'for outbound DMs' do
-        context 'not matching any admin-defined filters' do
-          let(:payload_file) { 'webhook1_direct_message.json' }
-
-          it 'returns 200' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'creates closed ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change(Ticket, :count).by(1)
-
-            expect(Ticket.last.attributes)
-              .to include(
-                'title'         => 'Hey! Hello!',
-                'state_id'      => Ticket::State.find_by(name: 'closed').id,
-                'priority_id'   => Ticket::Priority.find_by(name: '2 normal').id,
-                'customer_id'   => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id,
-                'created_by_id' => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id
-              )
-          end
-
-          it 'creates first article on closed ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change { Ticket::Article.count }.by(1)
-
-            expect(Ticket::Article.last.attributes)
-              .to include(
-                'from'          => '@zammadhq',
-                'to'            => '@medenhofer',
-                'message_id'    => '1062015437679050760',
-                'created_by_id' => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id
-              )
-          end
-
-          it 'does not add any attachments to newly created ticket' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(Ticket::Article.last.attachments).to be_empty
-          end
-        end
-      end
-
-      context 'for inbound DMs' do
-        context 'matching admin-defined #hashtag filter, with a link to an image' do
-          let(:payload_file) { 'webhook2_direct_message.json' }
-
-          it 'returns 200' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'creates new ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change(Ticket, :count).by(1)
-
-            expect(Ticket.last.attributes)
-              .to include(
-                'title'       => 'Hello Zammad #zammad @znuny  Yeah! https://t.co/UfaCwi9cUb',
-                'state_id'    => Ticket::State.find_by(name: 'new').id,
-                'priority_id' => Ticket::Priority.find_by(name: '2 normal').id,
-                'customer_id' => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id,
-              )
-          end
-
-          it 'creates first article on new ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change { Ticket::Article.count }.by(1)
-
-            expect(Ticket::Article.last.attributes)
-              .to include(
-                'to'            => '@zammadhq',
-                'from'          => '@medenhofer',
-                'body'          => "Hello Zammad #zammad @znuny\n\nYeah! https://twitter.com/messages/media/1063077238797725700",
-                'message_id'    => '1063077238797725700',
-                'created_by_id' => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id
-              )
-          end
-
-          it 'does not add linked image as attachment to newly created ticket' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(Ticket::Article.last.attachments).to be_empty
-          end
-        end
-
-        context 'from same sender as previously imported DMs' do
-          let(:payload_file) { 'webhook3_direct_message.json' }
-
-          before { post '/api/v1/channels_twitter_webhook', **previous_webhook_payload, as: :json }
-
-          let(:previous_webhook_payload) do
-            JSON.parse(File.read(Rails.root.join('test/data/twitter/webhook2_direct_message.json'))).symbolize_keys
-          end
-
-          it 'returns 200' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'does not create new ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .not_to change(Ticket, :count)
-          end
-
-          it 'adds new article to existing, open ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change { Ticket::Article.count }.by(1)
-
-            expect(Ticket::Article.last.attributes)
-              .to include(
-                'to'            => '@zammadhq',
-                'from'          => '@medenhofer',
-                'body'          => 'Hello again!',
-                'message_id'    => '1063077238797725701',
-                'created_by_id' => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id,
-                'ticket_id'     => Ticket.find_by(title: 'Hello Zammad #zammad @znuny  Yeah! https://t.co/UfaCwi9cUb').id
-              )
-          end
-
-          it 'does not add any attachments to newly created ticket' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(Ticket::Article.last.attachments).to be_empty
-          end
-        end
-      end
-
-      context 'when receiving duplicate DMs' do
-        let(:payload_file) { 'webhook1_direct_message.json' }
-
-        it 'still returns 200' do
-          2.times { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-
-          expect(response).to have_http_status(:ok)
-        end
-
-        it 'does not create duplicate articles' do
-          expect do
-            2.times { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-          end.to change { Ticket::Article.count }.by(1)
-        end
-      end
-
-      context 'for tweets' do
-        context 'matching admin-defined #hashtag filter, with an image link' do
-          let(:payload_file) { 'webhook1_tweet.json' }
-
-          before do
-            stub_request(:get, 'http://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_bigger.jpg')
-              .to_return(status: 200, body: 'some_content')
-
-            stub_request(:get, 'https://pbs.twimg.com/media/DsFKfJRWkAAFEbo.jpg')
-              .to_return(status: 200, body: 'some_content')
-          end
-
-          it 'returns 200' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'creates a closed ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change(Ticket, :count).by(1)
-
-            expect(Ticket.last.attributes)
-              .to include(
-                'title'         => 'Hey @medenhofer !  #hello1234 https://t.co/f1kffFlwpN',
-                'state_id'      => Ticket::State.find_by(name: 'closed').id,
-                'priority_id'   => Ticket::Priority.find_by(name: '2 normal').id,
-                'customer_id'   => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id,
-                'created_by_id' => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id,
-              )
-          end
-
-          it 'creates first article on closed ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change { Ticket::Article.count }.by(1)
-
-            expect(Ticket::Article.last.attributes)
-              .to include(
-                'from'          => '@zammadhq',
-                'to'            => '@medenhofer',
-                'body'          => 'Hey @medenhofer !  #hello1234 https://twitter.com/zammadhq/status/1063212927510081536/photo/1',
-                'message_id'    => '1063212927510081536',
-                'created_by_id' => User.find_by(login: 'zammadhq', firstname: 'Zammad', lastname: 'Hq').id
-              )
-          end
-
-          it 'add linked image as attachment to newly created article' do
-            expect(Ticket::Article.last.attachments)
-              .to match_array(Store.where(filename: 'DsFKfJRWkAAFEbo.jpg'))
-          end
-        end
-
-        context 'longer than 140 characters (with no media links)' do
-          let(:payload_file) { 'webhook2_tweet.json' }
-
-          before do
-            stub_request(:get, 'http://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_bigger.jpg')
-              .to_return(status: 200, body: 'some_content')
-          end
-
-          it 'returns 200' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'creates a new ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change(Ticket, :count).by(1)
-
-            expect(Ticket.last.attributes)
-              .to include(
-                'title'         => '@znuny Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ...',
-                'state_id'      => Ticket::State.find_by(name: 'new').id,
-                'priority_id'   => Ticket::Priority.find_by(name: '2 normal').id,
-                'customer_id'   => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id,
-                'created_by_id' => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id,
-              )
-          end
-
-          it 'creates first article on new ticket' do
-            expect { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-              .to change { Ticket::Article.count }.by(1)
-
-            expect(Ticket::Article.last.attributes)
-              .to include(
-                'from'          => '@medenhofer',
-                'to'            => '@znuny',
-                'body'          => '@znuny Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lore',
-                'created_by_id' => User.find_by(login: 'medenhofer', firstname: 'Martin', lastname: 'Edenhofer').id,
-                'message_id'    => '1065035365336141825'
-              )
-          end
-
-          it 'does not add any attachments to newly created ticket' do
-            post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json
-
-            expect(Ticket::Article.last.attachments).to be_empty
-          end
-        end
-
-        context 'when receiving duplicate messages' do
-          let(:payload_file) { 'webhook1_tweet.json' }
-
-          it 'still returns 200' do
-            2.times { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-
-            expect(response).to have_http_status(:ok)
-          end
-
-          it 'does not create duplicate articles' do
-            expect do
-              2.times { post '/api/v1/channels_twitter_webhook', **webhook_payload, as: :json }
-            end.to change { Ticket::Article.count }.by(1)
-          end
-        end
-      end
-    end
-  end
-end

+ 1 - 0
spec/support/negated_matchers.rb

@@ -1,3 +1,4 @@
 RSpec::Matchers.define_negated_matcher :not_change, :change
 RSpec::Matchers.define_negated_matcher :not_include, :include
 RSpec::Matchers.define_negated_matcher :not_eq, :eq
+RSpec::Matchers.define_negated_matcher :not_raise_error, :raise_error

+ 0 - 70
test/data/twitter/webhook1_direct_message.json

@@ -1,70 +0,0 @@
-{
-  "headers" : {
-    "x-twitter-webhooks-signature" : "sha256=xXu7qrPhqXfo8Ot14c0si9HrdQdBNru5fkSdoMZi+Ms="
-  },
-  "params" : {
-    "for_user_id": "123",
-    "direct_message_events": [
-      {
-        "type": "message_create",
-        "id": "1062015437679050760",
-        "created_timestamp": "1542039186292",
-        "message_create": {
-          "target": {
-            "recipient_id": "456"
-          },
-          "sender_id": "123",
-          "source_app_id": "268278",
-          "message_data": {
-            "text": "Hey! Hello!",
-            "entities": {
-              "hashtags": [],
-              "symbols": [],
-              "user_mentions": [],
-              "urls": []
-            }
-          }
-        }
-      }
-    ],
-    "apps": {
-      "268278": {
-        "id": "268278",
-        "name": "Twitter Web Client",
-        "url": "http://twitter.com"
-      }
-    },
-    "users": {
-      "123": {
-        "id": "123",
-        "created_timestamp": "1476091912921",
-        "name": "Zammad HQ",
-        "screen_name": "zammadhq",
-        "description": "Helpdesk and Customer Support made easy. Open Source for download or to go with SaaS. #zammad",
-        "url": "https://t.co/XITyrXmhTP",
-        "protected": false,
-        "verified": false,
-        "followers_count": 426,
-        "friends_count": 509,
-        "statuses_count": 436,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg"
-      },
-      "456": {
-        "id": "456",
-        "created_timestamp": "1290730789000",
-        "name": "Martin Edenhofer",
-        "screen_name": "medenhofer",
-        "description": "Open Source professional and geek. Also known as #OTRS and #Zammad inventor. ;)\r\nEntrepreneur and Advisor for open source people in need.",
-        "url": "https://t.co/whm4HTWdMw",
-        "protected": false,
-        "verified": false,
-        "followers_count": 312,
-        "friends_count": 314,
-        "statuses_count": 222,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg"
-      }
-    }
-  }
-}

+ 0 - 167
test/data/twitter/webhook1_tweet.json

@@ -1,167 +0,0 @@
-{
-  "headers": {
-    "x-twitter-webhooks-signature": "sha256=DmARpz6wdgte6Vj+ePeqC+RHvEDokmwOIIqr4//utkk="
-  },
-  "params": {
-    "for_user_id": "123",
-    "tweet_create_events": [
-      {
-        "created_at": "Thu Nov 15 23:31:30 +0000 2018",
-        "id": 1063212927510081536,
-        "id_str": "1063212927510081536",
-        "text": "Hey @medenhofer !  #hello1234 https://t.co/f1kffFlwpN",
-        "display_text_range": [0, 29],
-        "source": "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>",
-        "truncated": false,
-        "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,
-        "user": {
-          "id": 123,
-          "id_str": "123",
-          "name": "Zammad HQ",
-          "screen_name": "zammadhq",
-          "location": null,
-          "url": "http://zammad.com",
-          "description": "Helpdesk and Customer Support made easy. Open Source for download or to go with SaaS. #zammad",
-          "translator_type": "none",
-          "protected": false,
-          "verified": false,
-          "followers_count": 427,
-          "friends_count": 512,
-          "listed_count": 20,
-          "favourites_count": 280,
-          "statuses_count": 438,
-          "created_at": "Mon Oct 10 09:31:52 +0000 2016",
-          "utc_offset": null,
-          "time_zone": null,
-          "geo_enabled": false,
-          "lang": "en",
-          "contributors_enabled": false,
-          "is_translator": false,
-          "profile_background_color": "000000",
-          "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
-          "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
-          "profile_background_tile": false,
-          "profile_link_color": "31B068",
-          "profile_sidebar_border_color": "000000",
-          "profile_sidebar_fill_color": "000000",
-          "profile_text_color": "000000",
-          "profile_use_background_image": false,
-          "profile_image_url": "http://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg", "profile_image_url_https": "https://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg",
-          "profile_banner_url": "https://pbs.twimg.com/profile_banners/123/1476097853",
-          "default_profile": false,
-          "default_profile_image": false,
-          "following": null,
-          "follow_request_sent": null,
-          "notifications": null
-        },
-        "geo": null,
-        "coordinates": null,
-        "place": null,
-        "contributors": null,
-        "is_quote_status": false,
-        "quote_count": 0,
-        "reply_count": 0,
-        "retweet_count": 0,
-        "favorite_count": 0,
-        "entities": {
-          "hashtags": [
-            {"text": "hello1234", "indices": [19, 29]}
-          ],
-          "urls": [],
-          "user_mentions": [
-            {
-              "screen_name": "medenhofer",
-              "name": "Martin Edenhofer",
-              "id": 456,
-              "id_str": "456",
-              "indices": [4, 15]
-            }
-          ],
-          "symbols": [],
-          "media": [
-            {
-              "id": 1063212885961248768,
-              "id_str": "1063212885961248768",
-              "indices": [30, 53],
-              "media_url": "http://pbs.twimg.com/media/DsFKfJRWkAAFEbo.jpg",
-              "media_url_https": "https://pbs.twimg.com/media/DsFKfJRWkAAFEbo.jpg",
-              "url": "https://t.co/f1kffFlwpN",
-              "display_url": "pic.twitter.com/f1kffFlwpN",
-              "expanded_url": "https://twitter.com/zammadhq/status/1063212927510081536/photo/1",
-              "type": "photo",
-              "sizes": {
-                "thumb": {
-                  "w": 150,
-                  "h": 150,
-                  "resize": "crop"
-                },
-                "large": {
-                  "w": 852,
-                  "h": 462,
-                  "resize": "fit"
-                },
-                "medium": {
-                  "w": 852,
-                  "h": 462,
-                  "resize": "fit"
-                },
-                "small": {
-                  "w": 680,
-                  "h": 369,
-                  "resize": "fit"
-                }
-              }
-            }
-          ]
-        },
-        "extended_entities": {
-          "media": [
-            {
-              "id": 1063212885961248768,
-              "id_str": "1063212885961248768",
-              "indices": [30, 53],
-              "media_url": "http://pbs.twimg.com/media/DsFKfJRWkAAFEbo.jpg",
-              "media_url_https": "https://pbs.twimg.com/media/DsFKfJRWkAAFEbo.jpg",
-              "url": "https://t.co/f1kffFlwpN",
-              "display_url": "pic.twitter.com/f1kffFlwpN",
-              "expanded_url": "https://twitter.com/zammadhq/status/1063212927510081536/photo/1",
-              "type": "photo",
-              "sizes": {
-                "thumb": {
-                  "w": 150,
-                  "h": 150,
-                  "resize": "crop"
-                },
-                "large": {
-                  "w": 852,
-                  "h": 462,
-                  "resize": "fit"
-                },
-                "medium": {
-                  "w": 852,
-                  "h": 462,
-                  "resize": "fit"
-                },
-                "small": {
-                  "w": 680,
-                  "h": 369,
-                  "resize": "fit"
-                }
-              }
-            }
-          ]
-        },
-        "favorited": false,
-        "retweeted": false,
-        "possibly_sensitive": false,
-        "filter_level": "low",
-        "lang": "und",
-        "timestamp_ms": "1542324690116"
-      }
-    ]
-  }
-}

+ 0 - 118
test/data/twitter/webhook2_direct_message.json

@@ -1,118 +0,0 @@
-{
-  "headers" : {
-    "x-twitter-webhooks-signature" : "sha256=wYiCk7gfAgrnerCpj3XD58hozfVDjcQvcYPZCFH+stU="
-  },
-  "params": {
-    "for_user_id": "123",
-    "direct_message_events": [
-      {
-        "type": "message_create",
-        "id": "1063077238797725700",
-        "created_timestamp": "1542292339406",
-        "message_create": {
-          "target": {
-            "recipient_id": "123"
-          },
-          "sender_id": "456",
-          "message_data": {
-            "text": "Hello Zammad #zammad @znuny\n\nYeah! https://t.co/UfaCwi9cUb",
-            "entities": {
-              "hashtags": [
-                {
-                  "text": "zammad",
-                  "indices": [13,20]
-                }
-              ],
-              "symbols": [],
-              "user_mentions": [
-                {
-                  "screen_name": "znuny",
-                  "name": "Znuny / ES for OTRS",
-                  "id": 789,
-                  "id_str": "789",
-                  "indices": [21, 27]
-                }
-              ],
-              "urls": [
-                {
-                  "url": "https://t.co/UfaCwi9cUb",
-                  "expanded_url": "https://twitter.com/messages/media/1063077238797725700",
-                  "display_url": "pic.twitter.com/UfaCwi9cUb",
-                  "indices": [35, 58]
-                }
-              ]
-            },
-            "attachment": {
-              "type": "media",
-              "media": {
-                "id": 1063077198536556545,
-                "id_str": "1063077198536556545",
-                "indices": [35, 58],
-                "media_url": "https://ton.twitter.com/1.1/ton/data/dm/1063077238797725700/1063077198536556545/9FZgsMdV.jpg",
-                "media_url_https": "https://ton.twitter.com/1.1/ton/data/dm/1063077238797725700/1063077198536556545/9FZgsMdV.jpg",
-                "url": "https://t.co/UfaCwi9cUb",
-                "display_url": "pic.twitter.com/UfaCwi9cUb",
-                "expanded_url": "https://twitter.com/messages/media/1063077238797725700",
-                "type": "photo",
-                "sizes": {
-                  "thumb": {
-                    "w": 150,
-                    "h": 150,
-                    "resize": "crop"
-                  },
-                  "medium": {
-                    "w": 1200,
-                    "h": 313,
-                    "resize": "fit"
-                  },
-                  "small": {
-                    "w": 680,
-                    "h": 177,
-                    "resize": "fit"
-                  },
-                  "large": {
-                    "w": 1472,
-                    "h": 384,
-                    "resize": "fit"
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    ],
-    "users": {
-      "456": {
-        "id": "456",
-        "created_timestamp": "1290730789000",
-        "name": "Martin Edenhofer",
-        "screen_name": "medenhofer",
-        "description": "Open Source professional and geek. Also known as #OTRS and #Zammad inventor. ;)\r\nEntrepreneur and Advisor for open source people in need.",
-        "url": "https://t.co/whm4HTWdMw",
-        "protected": false,
-        "verified": false,
-        "followers_count": 312,
-        "friends_count": 314,
-        "statuses_count": 222,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg"
-      },
-      "123": {
-        "id": "123",
-        "created_timestamp": "1476091912921",
-        "name": "Zammad HQ",
-        "screen_name": "zammadhq",
-        "description": "Helpdesk and Customer Support made easy. Open Source for download or to go with SaaS. #zammad",
-        "url": "https://t.co/XITyrXmhTP",
-        "protected": false,
-        "verified": false,
-        "followers_count": 427,
-        "friends_count": 512,
-        "statuses_count": 437,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg"
-      }
-    }
-  }
-}

+ 0 - 115
test/data/twitter/webhook2_tweet.json

@@ -1,115 +0,0 @@
-{
-  "headers": {
-    "x-twitter-webhooks-signature": "sha256=U7bglX19JitI2xuvyONAc0d/fowIFEeUzkEgnWdGyUM="
-  },
-  "params": {
-    "for_user_id": "123",
-    "tweet_create_events": [
-      {
-        "created_at": "Wed Nov 21 00:13:13 +0000 2018",
-        "id": 1065035365336141825,
-        "id_str": "1065035365336141825",
-        "text": "@znuny Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et… https://t.co/b9woj0QXNZ",
-        "source": "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>",
-        "truncated": true,
-        "in_reply_to_status_id": null,
-        "in_reply_to_status_id_str": null,
-        "in_reply_to_user_id": 123,
-        "in_reply_to_user_id_str": "123",
-        "in_reply_to_screen_name": "znuny",
-        "user": {
-          "id": 219826253,
-          "id_str": "219826253",
-          "name": "Martin Edenhofer",
-          "screen_name": "medenhofer",
-          "location": null,
-          "url": "http://edenhofer.de/",
-          "description": "Open Source professional and geek. Also known as #OTRS and #Zammad inventor. ;)\r\nEntrepreneur and Advisor for open source people in need.",
-          "translator_type": "regular",
-          "protected": false,
-          "verified": false,
-          "followers_count": 310,
-          "friends_count": 314,
-          "listed_count": 16,
-          "favourites_count": 129,
-          "statuses_count": 225,
-          "created_at": "Fri Nov 26 00:19:49 +0000 2010",
-          "utc_offset": null,
-          "time_zone": null,
-          "geo_enabled": false,
-          "lang": "en",
-          "contributors_enabled": false,
-          "is_translator": false,
-          "profile_background_color": "C0DEED",
-          "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
-          "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
-          "profile_background_tile": true, "profile_link_color": "0084B4", "profile_sidebar_border_color": "FFFFFF",
-          "profile_sidebar_fill_color": "DDEEF6",
-          "profile_text_color": "333333",
-          "profile_use_background_image": true,
-          "profile_image_url": "http://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg",
-          "profile_image_url_https": "https://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg",
-          "profile_banner_url": "https://pbs.twimg.com/profile_banners/219826253/1349428277",
-          "default_profile": false,
-          "default_profile_image": false,
-          "following": null,
-          "follow_request_sent": null,
-          "notifications": null
-        },
-        "geo": null,
-        "coordinates": null,
-        "place": null,
-        "contributors": null,
-        "is_quote_status": false,
-        "extended_tweet": {
-          "full_text": "@znuny Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lore",
-          "display_text_range": [0, 279],
-          "entities": {
-            "hashtags": [],
-            "urls": [],
-            "user_mentions": [
-              {
-                "screen_name": "znuny",
-                "name": "Znuny / ES for OTRS",
-                "id": 123,
-                "id_str": "123",
-                "indices": [0, 6]
-              }
-            ],
-            "symbols": []
-          }
-        },
-        "quote_count": 0,
-        "reply_count": 0,
-        "retweet_count": 0,
-        "favorite_count": 0,
-        "entities": {
-          "hashtags": [],
-          "urls": [
-            {
-              "url": "https://t.co/b9woj0QXNZ",
-              "expanded_url": "https://twitter.com/i/web/status/1065035365336141825",
-              "display_url": "twitter.com/i/web/status/1…",
-              "indices": [117, 140]
-            }
-          ],
-          "user_mentions": [
-            {
-              "screen_name": "znuny",
-              "name": "Znuny / ES for OTRS",
-              "id": 123,
-              "id_str": "123",
-              "indices": [0, 6]
-            }
-          ],
-          "symbols": []
-        },
-        "favorited": false,
-        "retweeted": false,
-        "filter_level": "low",
-        "lang": "ro",
-        "timestamp_ms": "1542759193153"
-      }
-    ]
-  }
-}

+ 0 - 62
test/data/twitter/webhook3_direct_message.json

@@ -1,62 +0,0 @@
-{
-  "headers" : {
-    "x-twitter-webhooks-signature" : "sha256=OTguUdchBdxNal/csZsRkytKL5srrUuezZ3hp/2E404="
-  },
-  "params" : {
-    "for_user_id": "123",
-    "direct_message_events": [
-      {
-        "type": "message_create",
-        "id": "1063077238797725701",
-        "created_timestamp": "1542292339406",
-        "message_create": {
-          "target": {
-            "recipient_id": "123"
-          },
-          "sender_id": "456",
-          "message_data": {
-            "text": "Hello again!",
-            "entities": {
-              "hashtags": [],
-              "symbols": [],
-              "user_mentions": [],
-              "urls": []
-            }
-          }
-        }
-      }
-    ],
-    "users": {
-      "456": {
-        "id": "456",
-        "created_timestamp": "1290730789000",
-        "name": "Martin Edenhofer",
-        "screen_name": "medenhofer",
-        "description": "Open Source professional and geek. Also known as #OTRS and #Zammad inventor. ;)\r\nEntrepreneur and Advisor for open source people in need.",
-        "url": "https://t.co/whm4HTWdMw",
-        "protected": false,
-        "verified": false,
-        "followers_count": 312,
-        "friends_count": 314,
-        "statuses_count": 222,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/794220000450150401/D-eFg44R_normal.jpg"
-      },
-      "123": {
-        "id": "123",
-        "created_timestamp": "1476091912921",
-        "name": "Zammad HQ",
-        "screen_name": "zammadhq",
-        "description": "Helpdesk and Customer Support made easy. Open Source for download or to go with SaaS. #zammad",
-        "url": "https://t.co/XITyrXmhTP",
-        "protected": false,
-        "verified": false,
-        "followers_count": 427,
-        "friends_count": 512,
-        "statuses_count": 437,
-        "profile_image_url": "http://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg",
-        "profile_image_url_https": "https://pbs.twimg.com/profile_images/785412960797745152/wxdIvejo_normal.jpg"
-      }
-    }
-  }
-}

+ 51 - 0
test/data/twitter/webhook_events/direct_message-incoming.yml

@@ -0,0 +1,51 @@
+--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+for_user_id: '2975699229'
+direct_message_events:
+- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+  type: message_create
+  id: '1206724334791753732'
+  created_timestamp: '1576540475925'
+  message_create: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    target: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+      recipient_id: '2975699229'
+    sender_id: '1205290247124217856'
+    message_data: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+      text: Yes, you are!
+      entities: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+        hashtags: []
+        symbols: []
+        user_mentions: []
+        urls: []
+users: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+  '1205290247124217856': !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    id: '1205290247124217856'
+    created_timestamp: '1576198570797'
+    name: pennbrooke
+    screen_name: pennbrooke1
+    location: London
+    description: More like penn-broke, amirite?
+    url: https://t.co/Y9Umsm0AJJ
+    protected: false
+    verified: false
+    followers_count: 1
+    friends_count: 1
+    statuses_count: 2
+    profile_image_url: http://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png
+    profile_image_url_https: https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png
+  '2975699229': !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    id: '2975699229'
+    created_timestamp: '1421116717416'
+    name: Ryan Lue
+    screen_name: ScruffyMcG
+    location: Taipei
+    description: I like turtles.
+    url: https://t.co/Y9Umsm0AJJ
+    protected: false
+    verified: false
+    followers_count: 31
+    friends_count: 57
+    statuses_count: 6
+    profile_image_url: http://pbs.twimg.com/profile_images/877073703330238464/fnxECj4z_normal.jpg
+    profile_image_url_https: https://pbs.twimg.com/profile_images/877073703330238464/fnxECj4z_normal.jpg
+controller: channels_twitter
+action: webhook_incoming

+ 51 - 0
test/data/twitter/webhook_events/direct_message-incoming_2.yml

@@ -0,0 +1,51 @@
+--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+for_user_id: '2975699229'
+direct_message_events:
+- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+  type: message_create
+  id: '1207556329834835973'
+  created_timestamp: '1576738839001'
+  message_create: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    target: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+      recipient_id: '2975699229'
+    sender_id: '1205290247124217856'
+    message_data: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+      text: and I'm a hungry, hungry hippo!
+      entities: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+        hashtags: []
+        symbols: []
+        user_mentions: []
+        urls: []
+users: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+  '1205290247124217856': !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    id: '1205290247124217856'
+    created_timestamp: '1576198570797'
+    name: pennbrooke
+    screen_name: pennbrooke1
+    location: London
+    description: More like penn-broke, amirite?
+    url: https://t.co/Y9Umsm0AJJ
+    protected: false
+    verified: false
+    followers_count: 0
+    friends_count: 1
+    statuses_count: 2
+    profile_image_url: http://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png
+    profile_image_url_https: https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png
+  '2975699229': !ruby/hash:ActiveSupport::HashWithIndifferentAccess
+    id: '2975699229'
+    created_timestamp: '1421116717416'
+    name: Ryan Lue
+    screen_name: ScruffyMcG
+    location: Taipei
+    description: I like turtles.
+    url: https://t.co/Y9Umsm0AJJ
+    protected: false
+    verified: false
+    followers_count: 31
+    friends_count: 56
+    statuses_count: 6
+    profile_image_url: http://pbs.twimg.com/profile_images/877073703330238464/fnxECj4z_normal.jpg
+    profile_image_url_https: https://pbs.twimg.com/profile_images/877073703330238464/fnxECj4z_normal.jpg
+controller: channels_twitter
+action: webhook_incoming

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