From 917de2a4178e9707354161e36d760b04678a76af Mon Sep 17 00:00:00 2001 From: Ryan Lue Date: Mon, 6 Jan 2020 14:36:29 +0800 Subject: [PATCH] Refactoring: Prep refactoring of Twitter webhook processing logic This commit rescopes test coverage added in 9678b1857 from TwitterSync#process_webhook to Channel::Driver::Twitter#process. When Zammad processes an incoming webhook event from Twitter, it enters: * ChannelsTwitterController#webhook_incoming, which thinly wraps * Channel::Driver::Twitter#process, which thinly wraps * TwitterSync#process_webhook The core logic for webhook processing is contained in this last method, so that's the method that test coverage was written around. After examining the classes more carefully, it seems that this logic doesn't quite belong in the TwitterSync class, which is principally for outbound communication to the Twitter API. Rather, it is more analogous to Channel::EmailParser#process (which takes an incoming email and converts it into the appropriate Zammad assets; i.e., users, tickets, and articles), and so will be migrated upstream into Channel::Driver::Twitter#process. This refactoring will be undertaken in subsequent commits. --- spec/lib/twitter_sync_spec.rb | 659 +----------------- spec/models/channel/driver/twitter_spec.rb | 649 +++++++++++++++++ ..._has_been_deleted_creates_a_new_ticket.yml | 0 ...res_error_when_retrieving_parent_tweet.yml | 0 ...es_the_sender_s_existing_avatar_record.yml | 0 ...reates_an_avatar_record_for_the_sender.yml | 0 ...been_imported_yet_creates_a_new_ticket.yml | 0 ...s_the_parent_tweet_via_the_twitter_api.yml | 0 ...res_it_as_an_attachment_on_the_article.yml | 0 9 files changed, 650 insertions(+), 658 deletions(-) create mode 100644 spec/models/channel/driver/twitter_spec.rb rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/after_parent_tweet_has_been_deleted_creates_a_new_ticket.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/after_parent_tweet_has_been_deleted_silently_ignores_error_when_retrieving_parent_tweet.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/from_known_user_updates_the_sender_s_existing_avatar_record.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/from_unknown_user_creates_an_avatar_record_for_the_sender.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/that_hasn_t_been_imported_yet_creates_a_new_ticket.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/that_hasn_t_been_imported_yet_retrieves_the_parent_tweet_via_the_twitter_api.yml (100%) rename test/data/vcr_cassettes/{lib/twitter_sync => models/channel/driver/twitter}/when_message_contains_a_media_attachment_e_g__jpg_stores_it_as_an_attachment_on_the_article.yml (100%) diff --git a/spec/lib/twitter_sync_spec.rb b/spec/lib/twitter_sync_spec.rb index cf85eaaeb..0088808ba 100644 --- a/spec/lib/twitter_sync_spec.rb +++ b/spec/lib/twitter_sync_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe TwitterSync do - subject(:twitter_sync) { described_class.new(channel.options[:auth], payload) } + subject(:twitter_sync) { described_class.new(channel.options[:auth]) } let(:channel) { create(:twitter_channel) } @@ -69,661 +69,4 @@ 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: [{ : }, { : }] } - 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: }] } - 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 diff --git a/spec/models/channel/driver/twitter_spec.rb b/spec/models/channel/driver/twitter_spec.rb new file mode 100644 index 000000000..50e4dde5e --- /dev/null +++ b/spec/models/channel/driver/twitter_spec.rb @@ -0,0 +1,649 @@ +require 'rails_helper' + +RSpec.describe Channel::Driver::Twitter do + subject(:channel) { create(:twitter_channel) } + + describe '#process', current_user_id: 1 do + # Twitter channels must be configured to know whose account they're monitoring. + subject(:channel) do + create(:twitter_channel, custom_options: { user: { id: payload[:for_user_id] } }) + end + + let(:payload) { YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]) } + + # https://git.znuny.com/zammad/zammad/-/issues/305 + 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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: [{ : }, { : }] } + 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 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 { described_class.new.process(payload, 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.process( + YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), + channel + ) + end + + it 'does not create duplicate ticket' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, 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.process( + YAML.safe_load( + File.read(Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming.yml')), + [ActiveSupport::HashWithIndifferentAccess] + ), + channel + ) + end + + let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_2.yml') } + + it 'uses existing ticket' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 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 { described_class.new.process(payload, 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.process( + YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), + channel + ) + end + + it 'does not create duplicate article' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 + described_class.new.process(payload, 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: }] } + 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 { described_class.new.process(payload, channel) } + .to change(Ticket, :count).by(1) + end + + context 'for duplicate tweets' do + before do + described_class.new.process( + YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), + channel + ) + end + + it 'does not create duplicate ticket' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, channel) } + .to change(Ticket, :count).by(1) + end + + it 'retrieves the parent tweet via the Twitter API' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, channel) } + .to change(Ticket, :count).by(1) + end + + it 'silently ignores error when retrieving parent tweet' do + expect { described_class.new.process(payload, channel) }.to not_raise_error + end + end + end + + context 'that was previously imported' do + # import parent tweet + before { described_class.new.process(parent_tweet_payload, channel) } + + it 'uses existing ticket' do + expect { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 { described_class.new.process(payload, 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 + described_class.new.process(payload, 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 { described_class.new.process(payload, 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 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 { described_class.new.process(payload, channel) } + .to change(Ticket::Article, :count).by(1) + .and change { Ticket::Article.exists?(article_attributes) }.to(true) + end + end + end + end +end diff --git a/test/data/vcr_cassettes/lib/twitter_sync/after_parent_tweet_has_been_deleted_creates_a_new_ticket.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/after_parent_tweet_has_been_deleted_creates_a_new_ticket.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/after_parent_tweet_has_been_deleted_creates_a_new_ticket.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/after_parent_tweet_has_been_deleted_creates_a_new_ticket.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/after_parent_tweet_has_been_deleted_silently_ignores_error_when_retrieving_parent_tweet.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/after_parent_tweet_has_been_deleted_silently_ignores_error_when_retrieving_parent_tweet.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/after_parent_tweet_has_been_deleted_silently_ignores_error_when_retrieving_parent_tweet.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/after_parent_tweet_has_been_deleted_silently_ignores_error_when_retrieving_parent_tweet.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/from_known_user_updates_the_sender_s_existing_avatar_record.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/from_known_user_updates_the_sender_s_existing_avatar_record.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/from_known_user_updates_the_sender_s_existing_avatar_record.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/from_known_user_updates_the_sender_s_existing_avatar_record.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/from_unknown_user_creates_an_avatar_record_for_the_sender.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/from_unknown_user_creates_an_avatar_record_for_the_sender.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/from_unknown_user_creates_an_avatar_record_for_the_sender.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/from_unknown_user_creates_an_avatar_record_for_the_sender.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/that_hasn_t_been_imported_yet_creates_a_new_ticket.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/that_hasn_t_been_imported_yet_creates_a_new_ticket.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/that_hasn_t_been_imported_yet_creates_a_new_ticket.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/that_hasn_t_been_imported_yet_creates_a_new_ticket.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/that_hasn_t_been_imported_yet_retrieves_the_parent_tweet_via_the_twitter_api.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/that_hasn_t_been_imported_yet_retrieves_the_parent_tweet_via_the_twitter_api.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/that_hasn_t_been_imported_yet_retrieves_the_parent_tweet_via_the_twitter_api.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/that_hasn_t_been_imported_yet_retrieves_the_parent_tweet_via_the_twitter_api.yml diff --git a/test/data/vcr_cassettes/lib/twitter_sync/when_message_contains_a_media_attachment_e_g__jpg_stores_it_as_an_attachment_on_the_article.yml b/test/data/vcr_cassettes/models/channel/driver/twitter/when_message_contains_a_media_attachment_e_g__jpg_stores_it_as_an_attachment_on_the_article.yml similarity index 100% rename from test/data/vcr_cassettes/lib/twitter_sync/when_message_contains_a_media_attachment_e_g__jpg_stores_it_as_an_attachment_on_the_article.yml rename to test/data/vcr_cassettes/models/channel/driver/twitter/when_message_contains_a_media_attachment_e_g__jpg_stores_it_as_an_attachment_on_the_article.yml