Refactoring: Utilize existing channel driver pattern for Twitter

The Channel model is built around a "driver" pattern,
where records may invoke a method (e.g., #fetch)
and that method call then delegates to the same method
in a given driver class (e.g., Channel::Driver::Twitter).

Originally, the controller that handles incoming Twitter webhook events
ignored this interface, directly instantiating the driver class
and invoking the desired method.

This commit hides this invocation behind the appropriate interface
(i.e., Channel#process vs. Channel::Driver::Twitter#process).
This commit is contained in:
Ryan Lue 2020-01-06 16:20:59 +08:00 committed by Thorsten Eckel
parent 917de2a417
commit f69d7a0962
3 changed files with 61 additions and 65 deletions

View file

@ -8,7 +8,7 @@ class ChannelsTwitterController < ApplicationController
before_action :validate_webhook_signature!, only: :webhook_incoming before_action :validate_webhook_signature!, only: :webhook_incoming
def webhook_incoming def webhook_incoming
::Channel::Driver::Twitter.new.process(params.permit!.to_h, @channel) @channel.process(params.permit!.to_h)
render json: {} render json: {}
end end

View file

@ -143,7 +143,7 @@ returns
=end =end
def process(payload, channel) def process(_adapter_options, payload, channel)
@client = TwitterSync.new(channel.options[:auth], payload) @client = TwitterSync.new(channel.options[:auth], payload)
@client.process_webhook(channel) @client.process_webhook(channel)
end end

View file

@ -43,7 +43,7 @@ RSpec.describe Channel::Driver::Twitter do
context 'from unknown user' do context 'from unknown user' do
it 'creates a User record for the sender' do it 'creates a User record for the sender' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(User, :count).by(1) .to change(User, :count).by(1)
.and change { User.exists?(sender_attributes) }.to(true) .and change { User.exists?(sender_attributes) }.to(true)
end end
@ -51,7 +51,7 @@ RSpec.describe Channel::Driver::Twitter do
it 'creates an Avatar record for the sender', :use_vcr do 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 # Why 2, and not 1? Avatar.add auto-generates a default (source: 'init') record
# before actually adding the specified (source: 'twitter') one. # before actually adding the specified (source: 'twitter') one.
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Avatar, :count).by_at_least(1) .to change(Avatar, :count).by_at_least(1)
.and change { Avatar.exists?(avatar_attributes) }.to(true) .and change { Avatar.exists?(avatar_attributes) }.to(true)
@ -59,7 +59,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates an Authorization record for the sender' do it 'creates an Authorization record for the sender' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Authorization, :count).by(1) .to change(Authorization, :count).by(1)
.and change { Authorization.exists?(authorization_attributes) }.to(true) .and change { Authorization.exists?(authorization_attributes) }.to(true)
end end
@ -75,14 +75,14 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'updates the senders existing User record' do it 'updates the senders existing User record' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(User, :count) .to not_change(User, :count)
.and not_change { user.reload.attributes.slice('login', 'firstname') } .and not_change { user.reload.attributes.slice('login', 'firstname') }
.and change { User.exists?(sender_attributes.except('login', 'firstname')) }.to(true) .and change { User.exists?(sender_attributes.except('login', 'firstname')) }.to(true)
end end
it 'updates the senders existing Avatar record', :use_vcr do it 'updates the senders existing Avatar record', :use_vcr do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Avatar, :count) .to not_change(Avatar, :count)
.and change { Avatar.exists?(avatar_attributes) }.to(true) .and change { Avatar.exists?(avatar_attributes) }.to(true)
@ -90,7 +90,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'updates the senders existing Authorization record' do it 'updates the senders existing Authorization record' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Authorization, :count) .to not_change(Authorization, :count)
.and change { Authorization.exists?(authorization_attributes) }.to(true) .and change { Authorization.exists?(authorization_attributes) }.to(true)
end end
@ -98,7 +98,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'for incoming DM' do context 'for incoming DM' do
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/direct_message-incoming.yml') }
include_examples 'for user processing' do include_examples 'for user processing' do
# Payload sent by Twitter is { ..., users: [{ <uid>: <sender> }, { <uid>: <receiver> }] } # Payload sent by Twitter is { ..., users: [{ <uid>: <sender> }, { <uid>: <receiver> }] }
@ -123,21 +123,20 @@ RSpec.describe Channel::Driver::Twitter do
let(:title) { payload[:direct_message_events].first[:message_create][:message_data][:text] } let(:title) { payload[:direct_message_events].first[:message_create][:message_data][:text] }
it 'creates a new ticket' do it 'creates a new ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
.and change { Ticket.exists?(ticket_attributes) }.to(true) .and change { Ticket.exists?(ticket_attributes) }.to(true)
end end
context 'for duplicate messages' do context 'for duplicate messages' do
before do before do
described_class.new.process( channel.process(
YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
channel
) )
end end
it 'does not create duplicate ticket' do it 'does not create duplicate ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and not_change(Ticket::Article, :count) .and not_change(Ticket::Article, :count)
end end
@ -149,7 +148,7 @@ RSpec.describe Channel::Driver::Twitter do
let(:title) { "#{'a' * 80}..." } let(:title) { "#{'a' * 80}..." }
it 'creates ticket with truncated title' do it 'creates ticket with truncated title' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
.and change { Ticket.exists?(ticket_attributes) }.to(true) .and change { Ticket.exists?(ticket_attributes) }.to(true)
end end
@ -158,19 +157,18 @@ RSpec.describe Channel::Driver::Twitter do
context 'in reply to existing thread/ticket' do context 'in reply to existing thread/ticket' do
# import parent DM # import parent DM
before do before do
described_class.new.process( channel.process(
YAML.safe_load( YAML.safe_load(
File.read(Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming.yml')), File.read(Rails.root.join('test/data/twitter/webhook_events/direct_message-incoming.yml')),
[ActiveSupport::HashWithIndifferentAccess] [ActiveSupport::HashWithIndifferentAccess]
), )
channel
) )
end end
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-incoming_2.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/direct_message-incoming_2.yml') }
it 'uses existing ticket' do it 'uses existing ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and not_change { Ticket.last.state } .and not_change { Ticket.last.state }
end end
@ -179,7 +177,7 @@ RSpec.describe Channel::Driver::Twitter do
before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) } before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) }
it 'creates a new ticket' do it 'creates a new ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
.and change { Ticket.exists?(ticket_attributes) }.to(true) .and change { Ticket.exists?(ticket_attributes) }.to(true)
end end
@ -189,7 +187,7 @@ RSpec.describe Channel::Driver::Twitter do
before { Ticket.last.update(state: Ticket::State.find_by(name: 'pending reminder')) } before { Ticket.last.update(state: Ticket::State.find_by(name: 'pending reminder')) }
it 'sets existing ticket to "open"' do it 'sets existing ticket to "open"' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and change { Ticket.last.state.name }.to('open') .and change { Ticket.last.state.name }.to('open')
end end
@ -240,30 +238,29 @@ RSpec.describe Channel::Driver::Twitter do
let(:user_ids) { payload[:users].values.map { |u| u[:id] } } let(:user_ids) { payload[:users].values.map { |u| u[:id] } }
it 'creates a new article' do it 'creates a new article' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket::Article, :count).by(1) .to change(Ticket::Article, :count).by(1)
.and change { Ticket::Article.exists?(article_attributes) }.to(true) .and change { Ticket::Article.exists?(article_attributes) }.to(true)
end end
context 'for duplicate messages' do context 'for duplicate messages' do
before do before do
described_class.new.process( channel.process(
YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
channel
) )
end end
it 'does not create duplicate article' do it 'does not create duplicate article' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket::Article, :count) .to not_change(Ticket::Article, :count)
end end
end end
context 'when message contains shortened (t.co) url' do 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') } 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 it 'replaces the t.co url for the original' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true) .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition
BODY BODY
@ -273,7 +270,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'for outgoing DM' do context 'for outgoing DM' do
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'direct_message-outgoing.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/direct_message-outgoing.yml') }
describe 'ticket creation' do describe 'ticket creation' do
let(:ticket_attributes) do let(:ticket_attributes) do
@ -291,7 +288,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a closed ticket' do it 'creates a closed ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
.and change { Ticket.exists?(ticket_attributes) }.to(true) .and change { Ticket.exists?(ticket_attributes) }.to(true)
end end
@ -340,16 +337,16 @@ RSpec.describe Channel::Driver::Twitter do
let(:user_ids) { payload[:users].values.map { |u| u[:id] } } let(:user_ids) { payload[:users].values.map { |u| u[:id] } }
it 'creates a new article' do it 'creates a new article' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket::Article, :count).by(1) .to change(Ticket::Article, :count).by(1)
.and change { Ticket::Article.exists?(article_attributes) }.to(true) .and change { Ticket::Article.exists?(article_attributes) }.to(true)
end end
context 'when message contains shortened (t.co) url' do 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') } 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 it 'replaces the t.co url for the original' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true) .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition Did you know about this? https://en.wikipedia.org/wiki/Frankenstein#Composition
BODY BODY
@ -357,10 +354,10 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'when message contains a media attachment (e.g., JPG)' do 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') } 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 it 'does not store it as an attachment on the article' do
described_class.new.process(payload, channel) channel.process(payload)
expect(Ticket::Article.last.attachments).to be_empty expect(Ticket::Article.last.attachments).to be_empty
end end
@ -369,7 +366,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'for incoming tweet' do context 'for incoming tweet' do
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/tweet_create-user_mention.yml') }
include_examples 'for user processing' do include_examples 'for user processing' do
# Payload sent by Twitter is { ..., tweet_create_events: [{ ..., user: <author> }] } # Payload sent by Twitter is { ..., tweet_create_events: [{ ..., user: <author> }] }
@ -392,43 +389,42 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a new ticket' do it 'creates a new ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
end end
context 'for duplicate tweets' do context 'for duplicate tweets' do
before do before do
described_class.new.process( channel.process(
YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess]), YAML.safe_load(File.read(payload_file), [ActiveSupport::HashWithIndifferentAccess])
channel
) )
end end
it 'does not create duplicate ticket' do it 'does not create duplicate ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and not_change(Ticket::Article, :count) .and not_change(Ticket::Article, :count)
end end
end end
context 'in response to existing tweet thread' do context 'in response to existing tweet thread' do
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-response.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/tweet_create-response.yml') }
let(:parent_tweet_payload) do let(:parent_tweet_payload) do
YAML.safe_load( YAML.safe_load(
File.read(Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention.yml')), File.read(Rails.root.join('test/data/twitter/webhook_events/tweet_create-user_mention.yml')),
[ActiveSupport::HashWithIndifferentAccess] [ActiveSupport::HashWithIndifferentAccess]
) )
end end
context 'that hasnt been imported yet', :use_vcr do context 'that hasnt been imported yet', :use_vcr do
it 'creates a new ticket' do it 'creates a new ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
end end
it 'retrieves the parent tweet via the Twitter API' do it 'retrieves the parent tweet via the Twitter API' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket::Article, :count).by(2) .to change(Ticket::Article, :count).by(2)
expect(Ticket::Article.second_to_last.body).to eq(parent_tweet_payload[:tweet_create_events].first[:text]) expect(Ticket::Article.second_to_last.body).to eq(parent_tweet_payload[:tweet_create_events].first[:text])
@ -441,22 +437,22 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a new ticket' do it 'creates a new ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
end end
it 'silently ignores error when retrieving parent tweet' do it 'silently ignores error when retrieving parent tweet' do
expect { described_class.new.process(payload, channel) }.to not_raise_error expect { channel.process(payload) }.to not_raise_error
end end
end end
end end
context 'that was previously imported' do context 'that was previously imported' do
# import parent tweet # import parent tweet
before { described_class.new.process(parent_tweet_payload, channel) } before { channel.process(parent_tweet_payload) }
it 'uses existing ticket' do it 'uses existing ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and not_change { Ticket.last.state } .and not_change { Ticket.last.state }
end end
@ -465,7 +461,7 @@ RSpec.describe Channel::Driver::Twitter do
before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) } before { Ticket.last.update(state: Ticket::State.find_by(name: 'closed')) }
it 'sets existing ticket to "open"' do it 'sets existing ticket to "open"' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to not_change(Ticket, :count) .to not_change(Ticket, :count)
.and change { Ticket.last.state.name }.to('open') .and change { Ticket.last.state.name }.to('open')
end end
@ -516,38 +512,38 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a new article' do it 'creates a new article' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket::Article, :count).by(1) .to change(Ticket::Article, :count).by(1)
.and change { Ticket::Article.exists?(article_attributes) }.to(true) .and change { Ticket::Article.exists?(article_attributes) }.to(true)
end end
context 'when message mentions multiple users' do 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(: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(', @')}" } 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 it 'records all mentionees in comma-separated "to" attribute' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(to: mentionees) }.to(true) .to change { Ticket::Article.exists?(to: mentionees) }.to(true)
end end
end end
context 'when message exceeds 140 characters' do 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(: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] } let(:full_body) { payload[:tweet_create_events].first[:extended_tweet][:full_text] }
it 'records the full (extended) tweet body' do it 'records the full (extended) tweet body' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(body: full_body) }.to(true) .to change { Ticket::Article.exists?(body: full_body) }.to(true)
end end
end end
context 'when message contains shortened (t.co) url' do 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') } 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 it 'replaces the t.co url for the original' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true) .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
@ScruffyMcG https://zammad.org/ @ScruffyMcG https://zammad.org/
BODY BODY
@ -555,17 +551,17 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'when message contains a media attachment (e.g., JPG)' do 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') } 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 it 'replaces the t.co url for the original' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true) .to change { Ticket::Article.exists?(body: <<~BODY.chomp) }.to(true)
@ScruffyMcG https://twitter.com/pennbrooke1/status/1209101446706122752/photo/1 @ScruffyMcG https://twitter.com/pennbrooke1/status/1209101446706122752/photo/1
BODY BODY
end end
it 'stores it as an attachment on the article', :use_vcr do it 'stores it as an attachment on the article', :use_vcr do
described_class.new.process(payload, channel) channel.process(payload)
expect(Ticket::Article.last.attachments).to be_one expect(Ticket::Article.last.attachments).to be_one
end end
@ -574,7 +570,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
context 'for outgoing tweet' do context 'for outgoing tweet' do
let(:payload_file) { Rails.root.join('test', 'data', 'twitter', 'webhook_events', 'tweet_create-user_mention_outgoing.yml') } let(:payload_file) { Rails.root.join('test/data/twitter/webhook_events/tweet_create-user_mention_outgoing.yml') }
describe 'ticket creation' do describe 'ticket creation' do
let(:ticket_attributes) do let(:ticket_attributes) do
@ -592,7 +588,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a closed ticket' do it 'creates a closed ticket' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket, :count).by(1) .to change(Ticket, :count).by(1)
end end
end end
@ -639,7 +635,7 @@ RSpec.describe Channel::Driver::Twitter do
end end
it 'creates a new article' do it 'creates a new article' do
expect { described_class.new.process(payload, channel) } expect { channel.process(payload) }
.to change(Ticket::Article, :count).by(1) .to change(Ticket::Article, :count).by(1)
.and change { Ticket::Article.exists?(article_attributes) }.to(true) .and change { Ticket::Article.exists?(article_attributes) }.to(true)
end end