2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Refactor Chat::ListChannelMessages service a bit

- improve the contract a little
- use `model` where possible
- extract message existence logic to a dedicated policy, allowing easier
  testing.
- remove unused code
- refactor specs to follow current guidelines/best practices
This commit is contained in:
Loïc Guitaut 2025-05-23 15:24:56 +02:00 committed by Loïc Guitaut
parent 8337b49fe8
commit 3fbb2954cb
8 changed files with 295 additions and 333 deletions

View file

@ -85,6 +85,10 @@ module Service
return super if args.present? return super if args.present?
store[method_name] store[method_name]
end end

def respond_to_missing?(name, include_all)
store.key?(name) || super
end
end end


# Internal module to define available steps as DSL # Internal module to define available steps as DSL

View file

@ -4,6 +4,8 @@ module Chat
class MessagesSerializer < ::ApplicationSerializer class MessagesSerializer < ::ApplicationSerializer
attributes :messages, :tracking, :meta attributes :messages, :tracking, :meta


delegate :metadata, to: :object, private: true

def initialize(object, opts) def initialize(object, opts)
super(object, opts) super(object, opts)
@opts = opts @opts = opts
@ -30,9 +32,9 @@ module Chat


def meta def meta
{ {
target_message_id: object.target_message_id, target_message_id: metadata[:target_message]&.id,
can_load_more_future: object.can_load_more_future, can_load_more_future: metadata[:can_load_more_future],
can_load_more_past: object.can_load_more_past, can_load_more_past: metadata[:can_load_more_past],
} }
end end
end end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true

class Chat::Channel::Policy::MessageExistence < Service::PolicyBase
delegate :channel, :target_message_id, to: :context

def call
return true if target_message_id.blank?
return false if target_message.blank?
return true if !target_message.trashed?
if target_message.trashed? && target_message.user_id == guardian.user.id || guardian.is_staff?
return true
end
context[:target_message_id] = nil
true
end

def reason
end

private

def target_message
@target_message ||=
Chat::Message.with_deleted.find_by(id: target_message_id, chat_channel: channel)
end
end

View file

@ -5,7 +5,7 @@ module Chat
# or fetching paginated messages from last read. # or fetching paginated messages from last read.
# #
# @example # @example
# Chat::ListChannelMessages.call(params: { channel_id: 2, **optional_params }, guardian: guardian) # Chat::ListChannelMessages.call(params: { channel_id: 2, **optional_params }, guardian:)
# #
class ListChannelMessages class ListChannelMessages
include Service::Base include Service::Base
@ -30,8 +30,10 @@ module Chat
validates :channel_id, presence: true validates :channel_id, presence: true
validates :page_size, validates :page_size,
numericality: { numericality: {
less_than_or_equal_to: ::Chat::MessagesQuery::MAX_PAGE_SIZE, less_than_or_equal_to: Chat::MessagesQuery::MAX_PAGE_SIZE,
greater_than_or_equal_to: 1,
only_integer: true, only_integer: true,
only_numeric: true,
}, },
allow_nil: true allow_nil: true
validates :direction, validates :direction,
@ -39,19 +41,21 @@ module Chat
in: Chat::MessagesQuery::VALID_DIRECTIONS, in: Chat::MessagesQuery::VALID_DIRECTIONS,
}, },
allow_nil: true allow_nil: true

after_validation { self.page_size ||= Chat::MessagesQuery::MAX_PAGE_SIZE }
end end


model :channel model :channel
policy :can_view_channel policy :can_view_channel
model :membership, optional: true model :membership, optional: true
step :enabled_threads? model :target_message_id, optional: true
step :determine_target_message_id policy :target_message_exists, class_name: Chat::Channel::Policy::MessageExistence
policy :target_message_exists model :metadata, optional: true
step :fetch_messages model :messages, optional: true
step :fetch_thread_ids model :thread_ids, optional: true
step :fetch_tracking model :tracking, optional: true
step :fetch_thread_participants model :thread_participants, optional: true
step :fetch_thread_memberships model :thread_memberships, optional: true
step :update_membership_last_viewed_at step :update_membership_last_viewed_at
step :update_user_last_channel step :update_user_last_channel


@ -65,107 +69,57 @@ module Chat
channel.membership_for(guardian.user) channel.membership_for(guardian.user)
end end


def enabled_threads?(channel:)
context[:enabled_threads] = channel.threading_enabled
end

def can_view_channel(guardian:, channel:) def can_view_channel(guardian:, channel:)
guardian.can_preview_chat_channel?(channel) guardian.can_preview_chat_channel?(channel)
end end


def determine_target_message_id(params:, membership:) def fetch_target_message_id(params:, membership:)
if params.fetch_from_last_read return params.target_message_id unless params.fetch_from_last_read
context[:target_message_id] = membership&.last_read_message_id membership&.last_read_message_id
else
context[:target_message_id] = params.target_message_id
end
end end


def target_message_exists(channel:, guardian:) def fetch_metadata(channel:, guardian:, target_message_id:, params:)
return true if context.target_message_id.blank? ::Chat::MessagesQuery.call(

channel:,
target_message = guardian:,
Chat::Message.with_deleted.find_by(id: context.target_message_id, chat_channel: channel) target_message_id:,
return false if target_message.blank? include_thread_messages: !channel.threading_enabled?,

**params.slice(:page_size, :direction, :target_date),
return true if !target_message.trashed?
if target_message.trashed? && target_message.user_id == guardian.user.id || guardian.is_staff?
return true
end

context[:target_message_id] = nil
true
end

def fetch_messages(channel:, params:, guardian:, enabled_threads:, target_message_id:)
messages_data =
::Chat::MessagesQuery.call(
channel:,
guardian:,
target_message_id:,
include_thread_messages: !enabled_threads,
page_size: params.page_size || Chat::MessagesQuery::MAX_PAGE_SIZE,
direction: params.direction,
target_date: params.target_date,
)

context[:can_load_more_past] = messages_data[:can_load_more_past]
context[:can_load_more_future] = messages_data[:can_load_more_future]
context[:target_message_id] = messages_data[:target_message_id]

messages_data[:target_message] = (
if messages_data[:target_message]&.thread_reply? &&
(enabled_threads || messages_data[:target_message].thread&.force)
[]
else
[messages_data[:target_message]]
end
) )
end


context[:messages] = [ def fetch_messages(metadata:)
messages_data[:messages], [
messages_data[:past_messages]&.reverse, metadata[:messages],
messages_data[:target_message], metadata[:past_messages]&.reverse,
messages_data[:future_messages], (metadata[:target_message] unless metadata[:target_message]&.thread_reply?),
metadata[:future_messages],
].flatten.compact ].flatten.compact
end end


def fetch_tracking(guardian:)
context[:tracking] = {}

return if !context.thread_ids.present?

context[:tracking] = ::Chat::TrackingStateReportQuery.call(
guardian: guardian,
thread_ids: context.thread_ids,
include_threads: true,
)
end

def fetch_thread_ids(messages:) def fetch_thread_ids(messages:)
context[:thread_ids] = messages.map(&:thread_id).compact.uniq messages.filter_map(&:thread_id).uniq
end end


def fetch_thread_participants(messages:) def fetch_tracking(guardian:, thread_ids:)
return if context.thread_ids.empty? ::Chat::TrackingStateReportQuery.(guardian:, thread_ids:, include_threads: true)

context[:thread_participants] = ::Chat::ThreadParticipantQuery.call(
thread_ids: context.thread_ids,
)
end end


def fetch_thread_memberships(guardian:) def fetch_thread_participants(messages:, thread_ids:)
return if context.thread_ids.empty? return if thread_ids.blank?


context[:thread_memberships] = ::Chat::UserChatThreadMembership.where( ::Chat::ThreadParticipantQuery.(thread_ids:)
thread_id: context.thread_ids,
user_id: guardian.user.id,
)
end end


def update_membership_last_viewed_at(guardian:) def fetch_thread_memberships(guardian:, thread_ids:)
return if thread_ids.blank?

::Chat::UserChatThreadMembership.where(thread_id: thread_ids, user_id: guardian.user.id)
end

def update_membership_last_viewed_at(guardian:, membership:)
Scheduler::Defer.later "Chat::ListChannelMessages - defer update_membership_last_viewed_at" do Scheduler::Defer.later "Chat::ListChannelMessages - defer update_membership_last_viewed_at" do
context.membership&.update!(last_viewed_at: Time.zone.now) membership&.update!(last_viewed_at: Time.zone.now)
end end
end end



View file

@ -42,6 +42,7 @@ module Chat
model :membership, optional: true model :membership, optional: true
step :determine_target_message_id step :determine_target_message_id
policy :target_message_exists policy :target_message_exists
model :metadata, optional: true
model :messages, optional: true model :messages, optional: true


private private
@ -82,28 +83,26 @@ module Chat
target_message.user_id == guardian.user.id || guardian.is_staff? target_message.user_id == guardian.user.id || guardian.is_staff?
end end


def fetch_messages(thread:, guardian:, params:) def fetch_metadata(thread:, guardian:, params:)
messages_data = ::Chat::MessagesQuery.call(
::Chat::MessagesQuery.call( guardian:,
channel: thread.channel, channel: thread.channel,
guardian: guardian, target_message_id: context.target_message_id,
target_message_id: context.target_message_id, thread_id: thread.id,
thread_id: thread.id, page_size: params.page_size,
page_size: params.page_size, direction: params.direction,
direction: params.direction, target_date: params.target_date,
target_date: params.target_date, include_target_message_id:
include_target_message_id: params.fetch_from_first_message || params.fetch_from_last_message,
params.fetch_from_first_message || params.fetch_from_last_message, )
) end

context[:can_load_more_past] = messages_data[:can_load_more_past]
context[:can_load_more_future] = messages_data[:can_load_more_future]


def fetch_messages(metadata:)
[ [
messages_data[:messages], metadata[:messages],
messages_data[:past_messages]&.reverse, metadata[:past_messages]&.reverse,
messages_data[:target_message], metadata[:target_message],
messages_data[:future_messages], metadata[:future_messages],
].flatten.compact ].flatten.compact
end end
end end

View file

@ -0,0 +1,75 @@
# frozen_string_literal: true

RSpec.describe Chat::Channel::Policy::MessageExistence do
subject(:policy) { described_class.new(context) }

fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:channel) { Fabricate(:chat_channel) }

let(:guardian) { user.guardian }
let(:context) { Service::Base::Context.build(channel:, guardian:, target_message_id:) }

describe "#call" do
subject(:result) { policy.call }

context "when 'target_message_id' is not provided" do
let(:target_message_id) { nil }

it { is_expected.to be true }
end

context "when 'target_message_id' is provided" do
context "when target message does not exist" do
let(:target_message_id) { -1 }

it { is_expected.to be false }
end

context "when target message exists" do
fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user:) }

let(:target_message_id) { message.id }

context "when target message is not trashed" do
it { is_expected.to be true }
end

context "when target message is trashed" do
before { message.trash! }

context "when target messages user is the same as the guardian" do
it { is_expected.to be true }

it "does not set 'target_message_id' to nil" do
expect { result }.not_to change { context.target_message_id }
end
end

context "when target messages user is different than the guardian" do
fab!(:other_user) { Fabricate(:user, refresh_auto_groups: true) }

before { message.update!(user: other_user) }

context "when guardian is staff" do
let(:guardian) { Discourse.system_user.guardian }

it { is_expected.to be true }

it "does not set 'target_message_id' to nil" do
expect { result }.not_to change { context.target_message_id }
end
end

context "when guardian is not staff" do
it { is_expected.to be true }

it "sets 'target_message_id' to nil" do
expect { result }.to change { context.target_message_id }.to(nil)
end
end
end
end
end
end
end
end

View file

@ -1,267 +1,168 @@
# frozen_string_literal: true # frozen_string_literal: true


RSpec.describe Chat::ListChannelMessages do RSpec.describe Chat::ListChannelMessages do
subject(:result) { described_class.call(params:, **dependencies) } describe described_class::Contract, type: :model do
it { is_expected.to validate_presence_of(:channel_id) }
it { is_expected.to allow_values(1, Chat::MessagesQuery::MAX_PAGE_SIZE, nil).for(:page_size) }
it do
is_expected.not_to allow_values(0, Chat::MessagesQuery::MAX_PAGE_SIZE + 1).for(:page_size)
end
it do
is_expected.to validate_inclusion_of(:direction).in_array(
Chat::MessagesQuery::VALID_DIRECTIONS,
).allow_nil
end


fab!(:user) describe "#page_size" do
fab!(:channel) { Fabricate(:chat_channel) } let(:contract) { described_class.new }


let(:guardian) { Guardian.new(user) } context "when page_size is not set" do
let(:channel_id) { channel.id } it "defaults to MAX_PAGE_SIZE" do
let(:optional_params) { {} } contract.validate
let(:params) { { channel_id: }.merge(optional_params) } expect(contract.page_size).to eq(Chat::MessagesQuery::MAX_PAGE_SIZE)
let(:dependencies) { { guardian: } } end
end


before { channel.add(user) } context "when page_size is set to nil" do
before { contract.page_size = nil }


context "when contract" do it "defaults to MAX_PAGE_SIZE" do
context "when channel_id is not present" do contract.validate
expect(contract.page_size).to eq(Chat::MessagesQuery::MAX_PAGE_SIZE)
end
end

context "when page_size is set" do
before { contract.page_size = 5 }

it "does not change the value" do
contract.validate
expect(contract.page_size).to eq(5)
end
end
end
end

describe ".call" do
subject(:result) { described_class.call(params:, **dependencies) }

fab!(:user)
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }

let(:guardian) { Guardian.new(user) }
let(:channel_id) { channel.id }
let(:optional_params) { {} }
let(:params) { { channel_id:, **optional_params } }
let(:dependencies) { { guardian: } }

before { channel.add(user) }

context "when data is not valid" do
let(:channel_id) { nil } let(:channel_id) { nil }


it { is_expected.to fail_a_contract } it { is_expected.to fail_a_contract }
end end
end


context "when fetch_channel" do
context "when channel doesnt exist" do context "when channel doesnt exist" do
let(:channel_id) { -1 } let(:channel_id) { -1 }


it { is_expected.to fail_to_find_a_model(:channel) } it { is_expected.to fail_to_find_a_model(:channel) }
end end


context "when channel exists" do
it { is_expected.to run_successfully }

it "finds the correct channel" do
expect(result.channel).to eq(channel)
end
end
end

context "when fetch_eventual_membership" do
context "when user has membership" do
it { is_expected.to run_successfully }

it "finds the correct membership" do
expect(result.membership).to eq(channel.membership_for(user))
end
end

context "when user has no membership" do
before { channel.membership_for(user).destroy! }

it { is_expected.to run_successfully }

it "finds no membership" do
expect(result.membership).to be_blank
end
end
end

context "when enabled_threads?" do
context "when channel threading is disabled" do
before { channel.update!(threading_enabled: false) }

it "marks threads as disabled" do
expect(result.enabled_threads).to eq(false)
end
end

context "when channel and site setting are enabling threading" do
before { channel.update!(threading_enabled: true) }

it "marks threads as enabled" do
expect(result.enabled_threads).to eq(true)
end
end
end

context "when determine_target_message_id" do
context "when fetch_from_last_read is true" do
let(:optional_params) { { fetch_from_last_read: true } }

before do
channel.add(user)
channel.membership_for(user).update!(last_read_message_id: 1)
end

it "sets target_message_id to last_read_message_id" do
expect(result.target_message_id).to eq(1)
end
end
end

context "when target_message_exists" do
context "when no target_message_id is given" do
it { is_expected.to run_successfully }
end

context "when target message is not found" do context "when target message is not found" do
let(:optional_params) { { target_message_id: -1 } } let(:optional_params) { { target_message_id: -1 } }


it { is_expected.to fail_a_policy(:target_message_exists) } it { is_expected.to fail_a_policy(:target_message_exists) }
end end


context "when target message is found" do context "when everything is ok" do
fab!(:target_message) { Fabricate(:chat_message, chat_channel: channel) } fab!(:thread) { Fabricate(:chat_thread, channel:) }
let(:optional_params) { { target_message_id: target_message.id } } fab!(:messages) do
[thread.original_message, *Fabricate.times(20, :chat_message, chat_channel: channel)]
end

let(:tracking) { double }
let(:thread_ids) { [thread.id] }

before do
thread.add(user)
allow(Chat::TrackingStateReportQuery).to receive(:call).with(
guardian:,
thread_ids:,
include_threads: true,
).and_return(tracking)
end


it { is_expected.to run_successfully } it { is_expected.to run_successfully }
end


context "when target message is trashed" do it "finds the correct channel" do
fab!(:target_message) { Fabricate(:chat_message, chat_channel: channel) } expect(result.channel).to eq(channel)
let(:optional_params) { { target_message_id: target_message.id } } end


before { target_message.trash! } it "finds the correct membership" do
expect(result.membership).to eq(channel.membership_for(user))
end


context "when user is regular" do context "when fetch_from_last_read is true" do
it "nullifies target_message_id" do let(:optional_params) { { fetch_from_last_read: true } }
expect(result.target_message_id).to be_blank
before do
channel.add(user)
channel.membership_for(user).update!(last_read_message: messages.second)
end

it "sets target_message to last_read_message_id" do
expect(result.metadata[:target_message]).to eq(messages.second)
end end
end end


context "when user is the message creator" do
fab!(:target_message) { Fabricate(:chat_message, chat_channel: channel, user: user) }

it { is_expected.to run_successfully }
end

context "when user is admin" do
fab!(:user) { Fabricate(:admin) }

it { is_expected.to run_successfully }
end
end
end

context "when fetch_messages" do
context "with no params" do
fab!(:messages) { Fabricate.times(20, :chat_message, chat_channel: channel) }

it { is_expected.to be_a_success }

it "returns messages" do it "returns messages" do
expect(result.can_load_more_past).to eq(false) expect(result).to have_attributes(
expect(result.can_load_more_future).to eq(false) metadata: a_hash_including(can_load_more_future: false, can_load_more_past: false),
expect(result.messages).to contain_exactly(*messages) messages:,
end
end

context "when target_date is provided" do
fab!(:past_message) do
Fabricate(:chat_message, chat_channel: channel, created_at: 3.days.ago)
end
fab!(:future_message) do
Fabricate(:chat_message, chat_channel: channel, created_at: 1.days.ago)
end

let(:optional_params) { { target_date: 2.days.ago } }

it { is_expected.to be_a_success }

it "includes past and future messages" do
expect(result.messages).to eq([past_message, future_message])
end
end
end

context "when fetch_tracking" do
context "when threads are disabled" do
fab!(:thread_1) { Fabricate(:chat_thread, channel: channel) }

before do
channel.update!(threading_enabled: false)
thread_1.add(user)
end

it { is_expected.to be_a_success }

it "returns tracking" do
Fabricate(:chat_message, chat_channel: channel, thread: thread_1)

expect(result.tracking.thread_tracking).to eq(
{
thread_1.id => {
channel_id: channel.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 0,
},
},
) )
end end


context "when thread is forced" do context "when target_date is provided" do
before { thread_1.update!(force: true) } fab!(:past_message) do
Fabricate(:chat_message, chat_channel: channel, created_at: 3.days.ago)
end
fab!(:future_message) do
Fabricate(:chat_message, chat_channel: channel, created_at: 1.days.ago)
end


it { is_expected.to be_a_success } let(:optional_params) { { target_date: 2.days.ago } }


it "returns tracking" do it "includes past and future messages" do
Fabricate(:chat_message, chat_channel: channel, thread: thread_1) expect(result.messages).to include(past_message, future_message)

expect(result.tracking.thread_tracking).to eq(
{
thread_1.id => {
channel_id: channel.id,
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
},
},
)
end end
end end
end

context "when threads are enabled" do
fab!(:thread_1) { Fabricate(:chat_thread, channel: channel) }

before do
channel.update!(threading_enabled: true)
thread_1.add(user)
end

it { is_expected.to be_a_success }


it "returns tracking" do it "returns tracking" do
Fabricate(:chat_message, chat_channel: channel, thread: thread_1) expect(result.tracking).to eq(tracking)
end


expect(result.tracking.channel_tracking).to eq({}) it "updates the last viewed at" do
expect(result.tracking.thread_tracking).to eq( expect { result }.to change { channel.membership_for(user).last_viewed_at }.to be_within(
{ 1.second,
thread_1.id => { ).of(Time.zone.now)
channel_id: channel.id, end
mention_count: 0,
unread_count: 1, it "updates the custom field" do
watched_threads_unread_count: 0, expect { result }.to change { user.custom_fields[Chat::LAST_CHAT_CHANNEL_ID] }.from(nil).to(
}, channel.id,
},
) )
end end
end
end


context "when update_membership_last_viewed_at" do context "when custom field was already set" do
it "updates the last viewed at" do let(:field) { UserCustomField.find_by(name: Chat::LAST_CHAT_CHANNEL_ID, user_id: user.id) }
expect { result }.to change { channel.membership_for(user).last_viewed_at }.to be_within(
1.second,
).of(Time.zone.now)
end
end


context "when update_user_last_channel" do before { user.upsert_custom_fields(::Chat::LAST_CHAT_CHANNEL_ID => channel.id) }
it "updates the custom field" do
expect { result }.to change { user.custom_fields[Chat::LAST_CHAT_CHANNEL_ID] }.from(nil).to(
channel.id,
)
end


it "doesnt update the custom field when it was already set to this value" do it "doesnt update the custom field" do
user.upsert_custom_fields(::Chat::LAST_CHAT_CHANNEL_ID => channel.id) expect { result }.to_not change { field.reload.updated_at }
field = UserCustomField.find_by(name: Chat::LAST_CHAT_CHANNEL_ID, user_id: user.id) end

end
expect { result }.to_not change { field.reload.updated_at }
end end
end end
end end

View file

@ -149,9 +149,10 @@ RSpec.describe Chat::ListChannelThreadMessages do
end end


it "returns messages" do it "returns messages" do
expect(result.can_load_more_past).to eq(false) expect(result).to have_attributes(
expect(result.can_load_more_future).to eq(false) metadata: a_hash_including(can_load_more_future: false, can_load_more_past: false),
expect(result.messages).to contain_exactly(thread.original_message, *messages) messages: [thread.original_message, *messages],
)
end end
end end