0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-14 13:58:53 +08:00
discourse/plugins/discourse-assign/spec/plugin_spec.rb
Sam 0bad05d036
DEV: Extract reusable PostsFilter into core (#40436)
Promotes the posts query-string filter that previously lived in
discourse-ai's `Research::Filter` to a core `PostsFilter` class so it
can
be shared across core and plugins. The parser gains alias support
(`categories`, `exclude_category`, `exclude_tag`, `created_after`, ...),
`-`/`=`/`-=` exclusion prefixes, an `option_info` helper for
autocomplete,
and an `add_filter`/`remove_filter` extension API backed by a new
`posts_filter_options` plugin modifier.

Consumers are updated to build on the shared class:

* discourse-ai: `Research::Filter` becomes a thin subclass, the
researcher
  tool documents the new exclusion syntax, and the report context
generator builds its relation from a PostsFilter query string instead of
  hand-rolled SQL.
* discourse-assign: registers an `assigned_to:` filter (supporting
  `nobody`, `*` and usernames) and contributes its autocomplete entry.
* discourse-workflows: adds an `action:post` node with create/get/list
operations built on PostsFilter, and extracts a shared `PostHelper`
mixin
  reused by the existing create_post node.

Adds core locale strings and specs for the new class.

We also added two new APIs to node context to facilitate working with
posts:

#### `exec_ctx.create_post`

Creates a post while enforcing workflow actor permissions and preventing
recursive workflow execution.

```rb
post =
  exec_ctx.create_post(
    user: author,
    raw: "Reply body",
    topic_id: topic_id,
    reply_to_post_number: reply_to_post_number,
  )
```

Arguments:

- `user:` required `User` object used as the post author.
- `raw:` required raw post body.
- `topic_id:` required topic id where the post should be created.
- `reply_to_post_number:` optional post number to reply to.

The helper verifies that the author can see the topic, rejects closed or
archived topics, and creates the post with `skip_workflows: true`.

#### `exec_ctx.serialize_post`

Serializes a post into the standard Discourse Workflows post output
shape.

```rb
data =
  exec_ctx.serialize_post(
    post,
    guardian: actor.guardian,
    include_raw: true,
    include_cooked: false,
  )
```

Arguments:

- `post` required `Post` record.
- `guardian:` optional guardian used for permission-aware fields such as
visible tags. Defaults to the system guardian.
- `include_raw:` optional boolean. Defaults to `true`.
- `include_cooked:` optional boolean. Defaults to `false`.

Use this helper whenever a workflow node outputs post data. It keeps
post outputs consistent across action and trigger nodes.

---------

Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2026-06-02 14:52:47 +02:00

362 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe DiscourseAssign do
before { SiteSetting.assign_enabled = true }
describe "discourse-assign topics_filter_options modifier" do
let(:user) { Fabricate(:user) }
before do
SiteSetting.assign_allowed_on_groups = Group::AUTO_GROUPS[:staff]
user.update!(admin: true)
end
it "adds assigned filter option for users who can assign" do
guardian = user.guardian
options = TopicsFilter.option_info(guardian)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_present
expect(assigned_option).to include(
name: "assigned:",
description: I18n.t("discourse_assign.filter.description.assigned"),
type: "username_group_list",
priority: 1,
)
end
it "does not add assigned filter option for users who cannot assign" do
regular_user = Fabricate(:user)
guardian = regular_user.guardian
options = TopicsFilter.option_info(guardian)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_nil
end
it "does not add assigned filter option for anonymous users" do
options = TopicsFilter.option_info(Guardian.new)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_nil
end
end
describe "discourse-assign TopicsFilter filtering" do
fab!(:group)
fab!(:user)
fab!(:post_assignment) { Fabricate(:post_assignment, assigned_to: user) }
fab!(:topic_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
before do
SiteSetting.assign_allowed_on_groups = "#{group.id}"
group.add(user)
end
describe "with assigned:username" do
it "returns topics assigned to the specified user" do
filtered_topic_ids =
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("assigned:#{user.username}")
.pluck(:id)
expect(filtered_topic_ids).to contain_exactly(post_assignment.topic.id)
end
end
describe "with assigned:group" do
it "returns topics assigned to the specified group" do
filtered_topic_ids =
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("assigned:#{group.name}")
.pluck(:id)
expect(filtered_topic_ids).to contain_exactly(topic_assignment.topic.id)
end
describe "when querying private groups" do
fab!(:private_group) do
Fabricate(:group, visibility_level: Group.visibility_levels[:owners])
end
fab!(:private_topic_assignment) { Fabricate(:topic_assignment, assigned_to: private_group) }
it "does not return topics from private groups the user is not a member of" do
filtered_topic_ids =
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("assigned:#{private_group.name}")
.pluck(:id)
expect(filtered_topic_ids).to be_empty
end
it "does not return topics from private groups the user is a member of but lacks access to" do
private_group.add(user)
filtered_topic_ids =
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("assigned:#{private_group.name}")
.pluck(:id)
expect(filtered_topic_ids).to be_empty
end
it "returns topics from private groups the user has access to" do
private_group.add_owner(user)
filtered_topic_ids =
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("assigned:#{private_group.name}")
.pluck(:id)
expect(filtered_topic_ids).to contain_exactly(private_topic_assignment.topic.id)
end
end
end
end
describe "discourse-assign posts_filter_options modifier" do
let(:user) { Fabricate(:user) }
before do
SiteSetting.assign_allowed_on_groups = Group::AUTO_GROUPS[:staff]
user.update!(admin: true)
end
it "adds assigned_to filter option for users who can assign" do
options = PostsFilter.option_info(user.guardian)
assigned_option = options.find { |option| option[:name] == "assigned_to:" }
expect(assigned_option).to include(
name: "assigned_to:",
description: I18n.t("discourse_assign.filter.description.assigned"),
type: "username",
priority: 1,
)
end
it "does not add assigned_to filter option for users who cannot assign" do
options = PostsFilter.option_info(Fabricate(:user).guardian)
assigned_option = options.find { |option| option[:name] == "assigned_to:" }
expect(assigned_option).to be_nil
end
end
describe "discourse-assign PostsFilter filtering" do
fab!(:group)
fab!(:user)
fab!(:assigned_post, :post)
fab!(:other_assigned_post, :post)
fab!(:unassigned_post, :post)
fab!(:post_assignment) { Fabricate(:post_assignment, post: assigned_post, assigned_to: user) }
fab!(:other_assignment) do
Fabricate(:post_assignment, post: other_assigned_post, assigned_to: Fabricate(:user))
end
before do
SiteSetting.assign_allowed_on_groups = "#{group.id}"
group.add(user)
end
it "filters posts by assigned user" do
filtered_post_ids =
PostsFilter
.new("assigned_to:#{user.username}", guardian: Guardian.new(user))
.search
.pluck(:id)
expect(filtered_post_ids).to contain_exactly(assigned_post.id)
end
it "filters posts by assigned and unassigned topics" do
assigned_post_ids =
PostsFilter.new("assigned_to:*", guardian: Guardian.new(user)).search.pluck(:id)
unassigned_post_ids =
PostsFilter.new("assigned_to:nobody", guardian: Guardian.new(user)).search.pluck(:id)
expect(assigned_post_ids).to contain_exactly(assigned_post.id, other_assigned_post.id)
expect(unassigned_post_ids).to include(unassigned_post.id)
end
it "raises when the user cannot see assignments" do
expect do
PostsFilter
.new("assigned_to:#{user.username}", guardian: Guardian.new(Fabricate(:user)))
.search
.load
end.to raise_error(Discourse::InvalidAccess)
end
end
describe "Events" do
describe "on 'user_removed_from_group'" do
let(:group) { Fabricate(:group) }
let(:user) { Fabricate(:user) }
let(:first_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
let(:second_assignment) { Fabricate(:post_assignment, assigned_to: group) }
before do
group.users << user
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: first_assignment.id }.to_json,
)
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: second_assignment.id }.to_json,
)
end
it "removes user's notifications related to group assignments" do
expect { group.remove(user) }.to change { user.notifications.assigned.count }.by(-2)
end
end
describe "on 'user_added_to_group'" do
let(:group) { Fabricate(:group) }
let(:user) { Fabricate(:user) }
let!(:first_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
let!(:second_assignment) { Fabricate(:post_assignment, assigned_to: group) }
let!(:third_assignment) { Fabricate(:topic_assignment, assigned_to: group, active: false) }
it "creates missing notifications for added user" do
group.add(user)
[first_assignment, second_assignment].each do |assignment|
expect_job_enqueued(job: Jobs::AssignNotification, args: { assignment_id: assignment.id })
end
expect(
job_enqueued?(
job: Jobs::AssignNotification,
args: {
assignment_id: third_assignment.id,
},
),
).to eq(false)
end
end
describe "on 'topic_status_updated'" do
context "when closing a topic" do
let!(:first_assignment) { Fabricate(:topic_assignment) }
let!(:second_assignment) { Fabricate(:post_assignment, topic: topic) }
let(:topic) { first_assignment.topic }
before do
SiteSetting.unassign_on_close = true
topic.update_status("closed", true, Discourse.system_user)
end
it "deactivates existing assignments" do
[first_assignment, second_assignment].each do |assignment|
assignment.reload
expect(assignment).not_to be_active
expect_job_enqueued(
job: Jobs::UnassignNotification,
args: {
topic_id: assignment.topic_id,
assignment_id: assignment.id,
assigned_to_id: assignment.assigned_to_id,
assigned_to_type: assignment.assigned_to_type,
},
)
end
end
end
context "when reopening a topic" do
let!(:topic) { Fabricate(:closed_topic) }
let!(:first_assignment) { Fabricate(:topic_assignment, topic: topic, active: false) }
let!(:second_assignment) { Fabricate(:post_assignment, topic: topic, active: false) }
before do
SiteSetting.reassign_on_open = true
topic.update_status("closed", false, Discourse.system_user)
end
it "reactivates existing assignments" do
[first_assignment, second_assignment].each do |assignment|
assignment.reload
expect(assignment).to be_active
expect_job_enqueued(
job: Jobs::AssignNotification,
args: {
assignment_id: assignment.id,
},
)
end
end
end
end
describe "on 'post_destroyed'" do
let!(:assignment) { Fabricate(:post_assignment) }
let(:post) { assignment.target }
before { PostDestroyer.new(Discourse.system_user, post, context: "spec").destroy }
it "deactivates the existing assignment" do
assignment.reload
expect(assignment).not_to be_active
expect_job_enqueued(
job: Jobs::UnassignNotification,
args: {
topic_id: assignment.topic_id,
assignment_id: assignment.id,
assigned_to_id: assignment.assigned_to_id,
assigned_to_type: assignment.assigned_to_type,
},
)
end
end
describe "on 'post_recovered'" do
let!(:assignment) { Fabricate(:post_assignment, active: false) }
let(:post) { assignment.target }
before do
SiteSetting.reassign_on_open = true
post.trash!
PostDestroyer.new(Discourse.system_user, post, context: "spec").recover
end
it "reactivates the existing assignment" do
assignment.reload
expect(assignment).to be_active
expect_job_enqueued(job: Jobs::AssignNotification, args: { assignment_id: assignment.id })
end
end
describe "on 'group_destroyed'" do
let(:group) { Fabricate(:group) }
let(:user) { Fabricate(:user) }
let(:first_assignment) { Fabricate(:topic_assignment, assigned_to: group) }
let(:second_assignment) { Fabricate(:post_assignment, assigned_to: group) }
before do
group.users << user
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: first_assignment.id }.to_json,
)
Fabricate(
:notification,
notification_type: Notification.types[:assigned],
user: user,
data: { assignment_id: second_assignment.id }.to_json,
)
end
it "removes user's notifications related to group assignments" do
expect { group.destroy }.to change { user.notifications.assigned.count }.by(-2)
end
end
end
end