Commit 0c30b235 authored by Dmytro Zaporozhets's avatar Dmytro Zaporozhets

Merge branch '30526-b-be-wiki-activity-Services' into 'master'

#30526 (B) [BE] Wiki Events (services)

See merge request gitlab-org/gitlab!26533
parents 1b06748a 50e4641c
......@@ -52,10 +52,17 @@ class EventsFinder
if current_user && scope == 'all'
EventCollection.new(current_user.authorized_projects).all_project_events
else
source.events
# EventCollection is responsible for applying the feature flag
apply_feature_flags(source.events)
end
end
def apply_feature_flags(events)
return events if ::Feature.enabled?(:wiki_events)
events.not_wiki_page
end
# rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events)
events.merge(Project.public_or_visible_to_user(current_user))
......
......@@ -36,6 +36,8 @@ class Event < ApplicationRecord
expired: EXPIRED
).freeze
WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
milestone: Milestone,
......@@ -81,7 +83,10 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) }
scope :merged, -> { where(action: MERGED) }
scope :for_wiki_page, -> { where(target_type: WikiPage::Meta.name) }
scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
# Needed to implement feature flag: can be removed when feature flag is removed
scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') }
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
......@@ -229,7 +234,7 @@ class Event < ApplicationRecord
end
def wiki_page?
target_type == WikiPage::Meta.name
target_type == 'WikiPage::Meta'
end
def milestone
......
......@@ -33,16 +33,23 @@ class EventCollection
project_events
end
relation = apply_feature_flags(relation)
relation = paginate_events(relation)
relation.with_associations.to_a
end
def all_project_events
Event.from_union([project_events]).recent
apply_feature_flags(Event.from_union([project_events]).recent)
end
private
def apply_feature_flags(events)
return events if ::Feature.enabled?(:wiki_events)
events.not_wiki_page
end
def project_events
relation_with_join_lateral('project_id', projects)
end
......
......@@ -8,6 +8,8 @@
# EventCreateService.new.new_issue(issue, current_user)
#
class EventCreateService
IllegalActionError = Class.new(StandardError)
def open_issue(issue, current_user)
create_record_event(issue, current_user, Event::CREATED)
end
......@@ -80,6 +82,19 @@ class EventCreateService
create_push_event(BulkPushEventPayloadService, project, current_user, push_data)
end
# Create a new wiki page event
#
# @param [WikiPage::Meta] wiki_page_meta The event target
# @param [User] current_user The event author
# @param [Integer] action One of the Event::WIKI_ACTIONS
def wiki_event(wiki_page_meta, current_user, action)
return unless Feature.enabled?(:wiki_events)
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
create_record_event(wiki_page_meta, current_user, action)
end
private
def create_record_event(record, current_user, status)
......
# frozen_string_literal: true
module WikiPages
# There are 3 notions of 'action' that inheriting classes must implement:
#
# - external_action: the action we report to external clients with webhooks
# - usage_counter_action: the action that we count in out internal counters
# - event_action: what we record as the value of `Event#action`
class BaseService < ::BaseService
private
def execute_hooks(page, action = 'create')
page_data = Gitlab::DataBuilder::WikiPage.build(page, current_user, action)
def execute_hooks(page)
page_data = payload(page)
@project.execute_hooks(page_data, :wiki_page_hooks)
@project.execute_services(page_data, :wiki_page_hooks)
increment_usage(action)
increment_usage
create_wiki_event(page)
end
# Passed to web-hooks, and send to external consumers.
def external_action
raise NotImplementedError
end
# Passed to the WikiPageCounter to count events.
# Must be one of WikiPageCounter::KNOWN_EVENTS
def usage_counter_action
raise NotImplementedError
end
# Used to create `Event` records.
# Must be a valid value for `Event#action`
def event_action
raise NotImplementedError
end
def payload(page)
Gitlab::DataBuilder::WikiPage.build(page, current_user, external_action)
end
# This method throws an error if the action is an unanticipated value.
def increment_usage(action)
Gitlab::UsageDataCounters::WikiPageCounter.count(action)
def increment_usage
Gitlab::UsageDataCounters::WikiPageCounter.count(usage_counter_action)
end
def create_wiki_event(page)
return unless ::Feature.enabled?(:wiki_events)
slug = slug_for_page(page)
Event.transaction do
wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action)
end
end
def slug_for_page(page)
page.slug
end
end
end
......
......@@ -7,10 +7,22 @@ module WikiPages
page = WikiPage.new(project_wiki)
if page.create(@params)
execute_hooks(page, 'create')
execute_hooks(page)
end
page
end
def usage_counter_action
:create
end
def external_action
'create'
end
def event_action
Event::CREATED
end
end
end
......@@ -4,10 +4,22 @@ module WikiPages
class DestroyService < WikiPages::BaseService
def execute(page)
if page&.delete
execute_hooks(page, 'delete')
execute_hooks(page)
end
page
end
def usage_counter_action
:delete
end
def external_action
'delete'
end
def event_action
Event::DESTROYED
end
end
end
......@@ -3,11 +3,30 @@
module WikiPages
class UpdateService < WikiPages::BaseService
def execute(page)
# this class is not thread safe!
@old_slug = page.slug
if page.update(@params)
execute_hooks(page, 'update')
execute_hooks(page)
end
page
end
def usage_counter_action
:update
end
def external_action
'update'
end
def event_action
Event::UPDATED
end
def slug_for_page(page)
@old_slug.presence || super
end
end
end
......@@ -11,7 +11,7 @@ module EE
private
def execute_hooks(page, action = 'create')
def execute_hooks(page)
super
process_wiki_repository_update
end
......
......@@ -9,6 +9,7 @@ module API
expose :created_at
expose :note, using: Entities::Note, if: ->(event, options) { event.note? }
expose :author, using: Entities::UserBasic, if: ->(event, options) { event.author }
expose :wiki_page, using: Entities::WikiPageBasic, if: ->(event, _options) { event.wiki_page? }
expose :push_event_payload,
as: :push_data,
......
......@@ -9,6 +9,7 @@ class EventFilter
ISSUE = 'issue'
COMMENTS = 'comments'
TEAM = 'team'
WIKI = 'wiki'
def initialize(filter)
# Split using comma to maintain backward compatibility Ex/ "filter1,filter2"
......@@ -22,6 +23,8 @@ class EventFilter
# rubocop: disable CodeReuse/ActiveRecord
def apply_filter(events)
events = apply_feature_flags(events)
case filter
when PUSH
events.where(action: Event::PUSHED)
......@@ -33,6 +36,8 @@ class EventFilter
events.where(action: [Event::JOINED, Event::LEFT, Event::EXPIRED])
when ISSUE
events.where(action: [Event::CREATED, Event::UPDATED, Event::CLOSED, Event::REOPENED], target_type: 'Issue')
when WIKI
wiki_events(events)
else
events
end
......@@ -41,8 +46,20 @@ class EventFilter
private
def apply_feature_flags(events)
return events.not_wiki_page unless Feature.enabled?(:wiki_events)
events
end
def wiki_events(events)
return events unless Feature.enabled?(:wiki_events)
events.for_wiki_page
end
def filters
[ALL, PUSH, MERGED, ISSUE, COMMENTS, TEAM]
[ALL, PUSH, MERGED, ISSUE, COMMENTS, TEAM, WIKI]
end
end
......
......@@ -25,12 +25,12 @@ FactoryBot.define do
factory :wiki_page_event do
action { Event::CREATED }
project { @overrides[:wiki_page]&.project || create(:project, :wiki_repo) }
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do
wiki_page { create(:wiki_page, project: project) }
end
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
end
end
......
......@@ -3,7 +3,7 @@
require 'spec_helper'
describe EventsFinder do
let(:user) { create(:user) }
let_it_be(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:project1) { create(:project, :private, creator_id: user.id, namespace: user.namespace) }
......@@ -20,7 +20,7 @@ describe EventsFinder do
let(:opened_merge_request3) { create(:merge_request, source_project: project1, author: other_user) }
let!(:other_developer_event) { create(:event, project: project1, author: other_user, target: opened_merge_request3, action: Event::CREATED) }
let(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) }
let_it_be(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) }
let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) }
let!(:confidential_event) { create(:event, project: public_project, author: user, target: confidential_issue, action: Event::CLOSED) }
......@@ -59,6 +59,32 @@ describe EventsFinder do
end
end
describe 'wiki events feature flag' do
let_it_be(:events) { create_list(:wiki_page_event, 3, project: public_project) }
subject(:finder) { described_class.new(source: public_project, target_type: 'wiki', current_user: user) }
context 'the wiki_events feature flag is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'omits the wiki page events' do
expect(finder.execute).to be_empty
end
end
context 'the wiki_events feature flag is enabled' do
before do
stub_feature_flags(wiki_events: true)
end
it 'can find the wiki events' do
expect(finder.execute).to match_array(events)
end
end
end
context 'dashboard events' do
before do
project1.add_developer(other_user)
......
......@@ -28,6 +28,8 @@ describe EventFilter do
let_it_be(:comments_event) { create(:event, :commented, project: public_project, target: public_project) }
let_it_be(:joined_event) { create(:event, :joined, project: public_project, target: public_project) }
let_it_be(:left_event) { create(:event, :left, project: public_project, target: public_project) }
let_it_be(:wiki_page_event) { create(:wiki_page_event) }
let_it_be(:wiki_page_update_event) { create(:wiki_page_event, :updated) }
let(:filtered_events) { described_class.new(filter).apply_filter(Event.all) }
......@@ -77,6 +79,34 @@ describe EventFilter do
it 'returns all events' do
expect(filtered_events).to eq(Event.all)
end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end
context 'with the "wiki" filter' do
let(:filter) { described_class::WIKI }
it 'returns only wiki page events' do
expect(filtered_events).to contain_exactly(wiki_page_event, wiki_page_update_event)
end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).not_to include(wiki_page_event, wiki_page_update_event)
end
end
end
context 'with an unknown filter' do
......@@ -85,6 +115,16 @@ describe EventFilter do
it 'returns all events' do
expect(filtered_events).to eq(Event.all)
end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end
context 'with a nil filter' do
......@@ -93,6 +133,16 @@ describe EventFilter do
it 'returns all events' do
expect(filtered_events).to eq(Event.all)
end
context 'the :wiki_events filter is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not return wiki events' do
expect(filtered_events).to eq(Event.not_wiki_page)
end
end
end
end
......
......@@ -8,22 +8,68 @@ describe EventCollection do
let_it_be(:project) { create(:project_empty_repo, group: group) }
let_it_be(:projects) { Project.where(id: project.id) }
let_it_be(:user) { create(:user) }
let_it_be(:merge_request) { create(:merge_request) }
context 'with project events' do
let_it_be(:push_event_payloads) do
Array.new(9) do
create(:push_event_payload,
event: create(:push_event, project: project, author: user))
end
end
let_it_be(:merge_request_events) { create_list(:event, 10, :commented, project: project, target: merge_request) }
let_it_be(:closed_issue_event) { create(:closed_issue_event, project: project, author: user) }
let_it_be(:wiki_page_event) { create(:wiki_page_event, project: project) }
let(:push_events) { push_event_payloads.map(&:event) }
it 'returns an Array of events', :aggregate_failures do
most_recent_20_events = [
wiki_page_event,
closed_issue_event,
*push_events,
*merge_request_events
].sort_by(&:id).reverse.take(20)
events = described_class.new(projects).to_a
expect(events).to be_an_instance_of(Array)
expect(events).to match_array(most_recent_20_events)
end
context 'the wiki_events feature flag is disabled' do
before do
20.times do
event = create(:push_event, project: project, author: user)
stub_feature_flags(wiki_events: false)
end
it 'omits the wiki page events when using to_a' do
events = described_class.new(projects).to_a
create(:push_event_payload, event: event)
expect(events).not_to include(wiki_page_event)
end
it 'omits the wiki page events when using all_project_events' do
events = described_class.new(projects).all_project_events
expect(events).not_to include(wiki_page_event)
end
end
create(:closed_issue_event, project: project, author: user)
context 'the wiki_events feature flag is enabled' do
before do
stub_feature_flags(wiki_events: true)
end
it 'returns an Array of events' do
it 'includes the wiki page events when using to_a' do
events = described_class.new(projects).to_a
expect(events).to be_an_instance_of(Array)
expect(events).to include(wiki_page_event)
end
it 'includes the wiki page events when using all_project_events' do
events = described_class.new(projects).all_project_events
expect(events).to include(wiki_page_event)
end
end
it 'applies a limit to the number of events' do
......@@ -44,12 +90,25 @@ describe EventCollection do
expect(events).to be_empty
end
it 'allows filtering of events using an EventFilter' do
it 'allows filtering of events using an EventFilter, returning single item' do
filter = EventFilter.new(EventFilter::ISSUE)
events = described_class.new(projects, filter: filter).to_a
expect(events.length).to eq(1)
expect(events[0].action).to eq(Event::CLOSED)
expect(events).to contain_exactly(closed_issue_event)
end
it 'allows filtering of events using an EventFilter, returning several items' do
filter = EventFilter.new(EventFilter::COMMENTS)
events = described_class.new(projects, filter: filter).to_a
expect(events).to match_array(merge_request_events)
end
it 'allows filtering of events using an EventFilter, returning pushes' do
filter = EventFilter.new(EventFilter::PUSH)
events = described_class.new(projects, filter: filter).to_a
expect(events).to match_array(push_events)
end
end
......
......@@ -454,9 +454,10 @@ describe Event do
end
end
describe '.for_wiki_page' do
describe 'wiki_page predicate scopes' do
let_it_be(:events) do
[
create(:push_event),
create(:closed_issue_event),
create(:wiki_page_event),
create(:closed_issue_event),
......@@ -465,13 +466,25 @@ describe Event do
]
end
describe '.for_wiki_page' do
it 'only contains the wiki page events' do
wiki_events = events.select(&:wiki_page?)
expect(events).not_to match_array(wiki_events)
expect(described_class.for_wiki_page).to match_array(wiki_events)
end
end
describe '.not_wiki_page' do
it 'does not contain the wiki page events' do
non_wiki_events = events.reject(&:wiki_page?)
expect(events).not_to match_array(non_wiki_events)
expect(described_class.not_wiki_page).to match_array(non_wiki_events)
end
end
end
describe '#wiki_page and #wiki_page?' do
let_it_be(:project) { create(:project, :repository) }
......
......@@ -114,6 +114,26 @@ describe API::Events do
expect(json_response.size).to eq(1)
end
context 'when the list of events includes wiki page events' do
it 'returns information about the wiki event', :aggregate_failures do
page = create(:wiki_page, project: private_project)
[Event::CREATED, Event::UPDATED, Event::DESTROYED].each do |action|
create(:wiki_page_event, wiki_page: page, action: action, author: user)
end
get api("/users/#{user.id}/events", user)
wiki_events = json_response.select { |e| e['target_type'] == 'WikiPage::Meta' }
action_names = wiki_events.map { |e| e['action_name'] }
titles = wiki_events.map { |e| e['target_title'] }
slugs = wiki_events.map { |e| e.dig('wiki_page', 'slug') }
expect(action_names).to contain_exactly('created', 'updated', 'destroyed')
expect(titles).to all(eq(page.title))
expect(slugs).to all(eq(page.slug))
end
end
context 'when the list of events includes push events' do
let(:event) do
create(:push_event, author: user, project: private_project)
......
......@@ -153,6 +153,46 @@ describe EventCreateService do
end
end
describe '#wiki_event' do
let_it_be(:user) { create(:user) }
let_it_be(:wiki_page) { create(:wiki_page) }
let_it_be(:meta) { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
Event::WIKI_ACTIONS.each do |action|
context "The action is #{action}" do
let(:event) { service.wiki_event(meta, user, action) }
it 'creates the event' do
expect(event).to have_attributes(
wiki_page?: true,
valid?: true,
persisted?: true,
action: action,
wiki_page: wiki_page
)
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not create the event' do
expect { event }.not_to change(Event, :count)
end
end
end
end
(Event::ACTIONS.values - Event::WIKI_ACTIONS).each do |bad_action|
context "The action is #{bad_action}" do
it 'raises an error' do
expect { service.wiki_event(meta, user, bad_action) }.to raise_error(described_class::IllegalActionError)
end
end
end
end
describe '#push', :clean_gitlab_redis_shared_state do
let(:project) { create(:project) }
let(:user) { create(:user) }
......
......@@ -6,22 +6,24 @@ describe WikiPages::BaseService do
let(:project) { double('project') }
let(:user) { double('user') }
subject(:service) { described_class.new(project, user, {}) }
describe '#increment_usage' do
counter = Gitlab::UsageDataCounters::WikiPageCounter
error = counter::UnknownEvent
it 'raises an error on unknown events' do
expect { subject.send(:increment_usage, :bad_event) }.to raise_error error
end
let(:subject) { bad_service_class.new(project, user, {}) }
context 'the event is valid' do
counter::KNOWN_EVENTS.each do |e|
it "updates the #{e} counter" do
expect { subject.send(:increment_usage, e) }.to change { counter.read(e) }
context 'the class implements usage_counter_action incorrectly' do
let(:bad_service_class) do
Class.new(described_class) do
def usage_counter_action
:bad_event
end
end
end
it 'raises an error on unknown events' do
expect { subject.send(:increment_usage) }.to raise_error(error)
end
end
end
end
......@@ -5,19 +5,16 @@ require 'spec_helper'
describe WikiPages::CreateService do
let(:project) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:page_title) { 'Title' }
let(:opts) do
{
title: 'Title',
title: page_title,
content: 'Content for wiki page',
format: 'markdown'
}
end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) }
before do
......@@ -35,8 +32,7 @@ describe WikiPages::CreateService do
end
it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once
.with(instance_of(WikiPage), 'create')
expect(service).to receive(:execute_hooks).once.with(WikiPage)
service.execute
end
......@@ -47,8 +43,41 @@ describe WikiPages::CreateService do
expect { service.execute }.to change { counter.read(:create) }.by 1
end
shared_examples 'correct event created' do
it 'creates appropriate events' do
expect { service.execute }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::CREATED,
target: have_attributes(canonical_slug: page_title)
)
end
end
context 'the new page is at the top level' do
let(:page_title) { 'root-level-page' }
include_examples 'correct event created'
end
context 'the new page is in a subsection' do
let(:page_title) { 'subsection/page' }
include_examples 'correct event created'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
end
context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) }
let(:page_title) { '' }
it 'does not count a creation event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter
......@@ -56,6 +85,10 @@ describe WikiPages::CreateService do
expect { service.execute }.not_to change { counter.read(:create) }
end
it 'does not record the activity' do
expect { service.execute }.not_to change(Event, :count)
end
it 'reports the error' do
expect(service.execute).to be_invalid
.and have_attributes(errors: be_present)
......
......@@ -15,8 +15,7 @@ describe WikiPages::DestroyService do
describe '#execute' do
it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once
.with(instance_of(WikiPage), 'delete')
expect(service).to receive(:execute_hooks).once.with(page)
service.execute(page)
end
......@@ -27,10 +26,29 @@ describe WikiPages::DestroyService do
expect { service.execute(page) }.to change { counter.read(:delete) }.by 1
end
it 'creates a new wiki page deletion event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::DESTROYED,
target: have_attributes(canonical_slug: page.slug)
)
end
it 'does not increment the delete count if the deletion failed' do
counter = Gitlab::UsageDataCounters::WikiPageCounter
expect { service.execute(nil) }.not_to change { counter.read(:delete) }
end
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
end
......@@ -6,20 +6,17 @@ describe WikiPages::UpdateService do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:page) { create(:wiki_page) }
let(:page_title) { 'New Title' }
let(:opts) do
{
content: 'New content for wiki page',
format: 'markdown',
message: 'New wiki message',
title: 'New Title'
title: page_title
}
end
let(:bad_opts) do
{ title: '' }
end
subject(:service) { described_class.new(project, user, opts) }
before do
......@@ -34,12 +31,11 @@ describe WikiPages::UpdateService do
expect(updated_page.message).to eq(opts[:message])
expect(updated_page.content).to eq(opts[:content])
expect(updated_page.format).to eq(opts[:format].to_sym)
expect(updated_page.title).to eq(opts[:title])
expect(updated_page.title).to eq(page_title)
end
it 'executes webhooks' do
expect(service).to receive(:execute_hooks).once
.with(instance_of(WikiPage), 'update')
expect(service).to receive(:execute_hooks).once.with(WikiPage)
service.execute(page)
end
......@@ -50,8 +46,42 @@ describe WikiPages::UpdateService do
expect { service.execute page }.to change { counter.read(:update) }.by 1
end
shared_examples 'adds activity event' do
it 'adds a new wiki page activity event' do
expect { service.execute(page) }.to change { Event.count }.by 1
expect(Event.recent.first).to have_attributes(
action: Event::UPDATED,
wiki_page: page,
target_title: page.title
)
end
end
context 'the page is at the top level' do
let(:page_title) { 'Top level page' }
include_examples 'adds activity event'
end
context 'the page is in a subsection' do
let(:page_title) { 'Subsection / secondary page' }
include_examples 'adds activity event'
end
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
end
it 'does not record the activity' do
expect { service.execute(page) }.not_to change(Event, :count)
end
end
context 'when the options are bad' do
subject(:service) { described_class.new(project, user, bad_opts) }
let(:page_title) { '' }
it 'does not count an edit event' do
counter = Gitlab::UsageDataCounters::WikiPageCounter
......@@ -59,6 +89,10 @@ describe WikiPages::UpdateService do
expect { service.execute page }.not_to change { counter.read(:update) }
end
it 'does not record the activity' do
expect { service.execute page }.not_to change(Event, :count)
end
it 'reports the error' do
expect(service.execute page).to be_invalid
.and have_attributes(errors: be_present)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment