discourse/plugins/discourse-calendar/spec/requests/invitees_controller_spec.rb
Régis Hanol b4643f7151
FEATURE: Better RSVPs on recurring events (#39892)
For recurring events, members can now choose whether their "Going" RSVP
applies to a single upcoming instance or to every following instance in
the series. Previously, a "Going" RSVP silently persisted forever across
all future recurrences with no way to opt out short of leaving the
event.

The recurring `Going` button is now a dropdown with two options:

- "This event" (default): the RSVP applies only to the next occurrence
and is cleared when the recurrence advances.
- "This event and all following events": the RSVP persists across every
future occurrence until the member changes it.

Non-recurring events are unchanged and still expose a plain `Going`
button.

Implementation:

- New `recurring` boolean column on `discourse_post_event_invitees`,
defaulting to false.
- `Invitee#recurring=` coerces to a strict boolean and a `before_save`
callback enforces that only `going` rows can be recurring, so the
invariant is owned by the model.
- `Event#reset_invitee_notifications` (called on recurrence advance) now
also clears `going` rows that are not marked recurring, restoring the
original pre-regression "reset on each occurrence" behavior as the
default while keeping the opt-in persistent mode intact.
- `CreateInvitee` / `UpdateInvitee` services accept a `recurring` param
threaded through from the controller. The `InviteeSerializer` exposes
`recurring` so the UI can reflect the current selection.
- The participant chip and the trigger button swap between the `check`
and `arrows-rotate` icons depending on the invitee's `recurring` flag.

Ref - t/144138
2026-05-18 10:42:09 +02:00

414 lines
13 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscoursePostEvent
describe InviteesController do
before do
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
sign_in(user)
end
let(:user) { Fabricate(:user, admin: true) }
let(:topic_1) { Fabricate(:topic, user: user) }
let(:post_1) { Fabricate(:post, user: user, topic: topic_1) }
describe "#index" do
context "for a post in a private category" do
let(:outside_user) { Fabricate(:user) }
let(:in_group_user) { Fabricate(:user) }
let(:group) { Fabricate(:group, users: [in_group_user]) }
let(:private_category) { Fabricate(:private_category, group:) }
let(:topic_1) { Fabricate(:topic, user: user, category: private_category) }
let(:post_1) { Fabricate(:post, user: user, topic: topic_1) }
let(:post_event_1) { Fabricate(:event, post: post_1) }
it "forbids non group user from seeing the list of invitees" do
sign_in(outside_user)
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json"
expect(response.status).to eq(403)
end
it "allows group user to see the list of invitees" do
sign_in(in_group_user)
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json"
expect(response.status).to eq(200)
end
end
context "when params are included" do
let(:invitee1) { Fabricate(:user, username: "Francis", name: "Francis") }
let(:invitee2) { Fabricate(:user, username: "Francisco", name: "Francisco") }
let(:invitee3) { Fabricate(:user, username: "Frank", name: "Frank") }
let(:invitee4) { Fabricate(:user, username: "Franchesca", name: "Franchesca") }
let!(:random_user) { Fabricate(:user, username: "Franny") }
let(:post_event_1) do
pe = Fabricate(:event, post: post_1)
pe.create_invitees(
[
{ user_id: invitee1.id, status: Invitee.statuses[:going] },
{ user_id: invitee2.id, status: Invitee.statuses[:interested] },
{ user_id: invitee3.id, status: Invitee.statuses[:not_going] },
{ user_id: invitee4.id, status: Invitee.statuses[:going] },
],
)
pe
end
context "when user is allowed to act on post event" do
it "returns users extra suggested users when filtering the invitees by name" do
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
filter: "Fran",
type: "going",
}
suggested = response.parsed_body[:meta][:suggested_users].map { |u| u[:username] }.sort
expect(suggested).to eq(%w[Francisco Frank Franny])
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
filter: "",
type: "going",
}
suggested = response.parsed_body.dig(:meta, :suggested_users)
expect(suggested).to be_blank
end
end
it "returns the correct amount of users when filtering the invitees by name" do
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
filter: "Franc",
}
filteredInvitees = response.parsed_body["invitees"]
expect(filteredInvitees.count).to eq(3)
end
it "returns the correct amount of users when filtering the invitees by type" do
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
type: "interested",
}
filteredInvitees = response.parsed_body["invitees"]
expect(filteredInvitees.count).to eq(1)
end
it "returns the correct amount of users when filtering the invitees by name and type" do
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
filter: "Franc",
type: "going",
}
filteredInvitees = response.parsed_body["invitees"]
expect(filteredInvitees.count).to eq(2)
end
it "returns 400 when filtering by an invalid type" do
get "/discourse-post-event/events/#{post_event_1.id}/invitees.json",
params: {
type: "nonexistent",
}
expect(response.status).to eq(400)
end
end
end
describe "#create" do
let(:event) { Fabricate(:event, post: post_1) }
it "creates an invitee" do
post "/discourse-post-event/events/#{event.id}/invitees.json",
params: {
invitee: {
status: "going",
},
}
expect(response.status).to eq(200)
expect(Invitee).to exist(user_id: user.id, status: Invitee.statuses[:going])
end
it "allows staff to invite another user" do
other_user = Fabricate(:user)
post "/discourse-post-event/events/#{event.id}/invitees.json",
params: {
invitee: {
status: "interested",
user_id: other_user.id,
},
}
expect(response.status).to eq(200)
expect(Invitee).to exist(user_id: other_user.id, status: Invitee.statuses[:interested])
end
it "persists the recurring flag when joining" do
post "/discourse-post-event/events/#{event.id}/invitees.json",
params: {
invitee: {
status: "going",
recurring: true,
},
}
expect(response.status).to eq(200)
expect(Invitee).to exist(user_id: user.id, recurring: true)
end
it "returns 403 when non-staff tries to invite themselves to a private event" do
private_event = Fabricate(:event, post: post_1, status: "private")
other_user = Fabricate(:user)
sign_in(other_user)
expect do
post "/discourse-post-event/events/#{private_event.id}/invitees.json",
params: {
invitee: {
status: "going",
},
}
end.not_to change { Invitee.count }
expect(response.status).to eq(403)
end
context "when event is at max capacity" do
fab!(:post_2) { create_post(user: Fabricate(:admin), category: Fabricate(:category)) }
fab!(:user_a, :user)
fab!(:user_b, :user)
fab!(:full_event) do
pe = Fabricate(:event, post: post_2, max_attendees: 1)
pe.create_invitees([{ user_id: user_a.id, status: Invitee.statuses[:going] }])
pe
end
it "returns 422 when trying to join as going" do
sign_in(user_b)
post "/discourse-post-event/events/#{full_event.id}/invitees.json",
params: {
invitee: {
status: "going",
},
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"].join).to include("full")
end
it "allows interested when full" do
sign_in(user_b)
post "/discourse-post-event/events/#{full_event.id}/invitees.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(200)
end
end
end
describe "#update" do
let(:invitee_user) { Fabricate(:user) }
let(:event) do
pe = Fabricate(:event, post: post_1)
pe.create_invitees([{ user_id: invitee_user.id, status: Invitee.statuses[:going] }])
pe
end
let(:invitee) { event.invitees.first }
it "updates the invitee status" do
put "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(200)
expect(invitee.reload.status).to eq(Invitee.statuses[:interested])
end
it "persists the recurring flag when updating" do
put "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json",
params: {
invitee: {
status: "going",
recurring: true,
},
}
expect(response.status).to eq(200)
expect(invitee.reload.recurring).to eq(true)
end
it "returns 404 when invitee does not exist" do
put "/discourse-post-event/events/#{event.id}/invitees/999999.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(404)
end
it "returns 403 when user cannot act on invitee" do
lurker = Fabricate(:user)
sign_in(lurker)
put "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(403)
end
context "when event is at max capacity" do
fab!(:post_2) { create_post(user: Fabricate(:admin), category: Fabricate(:category)) }
fab!(:user_a, :user)
fab!(:user_b, :user)
fab!(:full_event) do
pe = Fabricate(:event, post: post_2, max_attendees: 1)
pe.create_invitees([{ user_id: user_a.id, status: Invitee.statuses[:going] }])
pe
end
fab!(:interested_invitee) do
Fabricate(
:invitee,
post_id: post_2.id,
user_id: user_b.id,
status: Invitee.statuses[:interested],
)
end
it "returns 422 when trying to change to going" do
sign_in(user_b)
put "/discourse-post-event/events/#{full_event.id}/invitees/#{interested_invitee.id}.json",
params: {
invitee: {
status: "going",
},
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"].join).to include("full")
end
it "allows changing to interested" do
sign_in(user_b)
put "/discourse-post-event/events/#{full_event.id}/invitees/#{interested_invitee.id}.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(200)
end
end
end
describe "#destroy" do
let(:invitee_user) { Fabricate(:user) }
let(:event) do
pe = Fabricate(:event, post: post_1)
pe.create_invitees([{ user_id: invitee_user.id, status: Invitee.statuses[:going] }])
pe
end
let(:invitee) { event.invitees.first }
it "destroys the invitee as staff" do
delete "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json"
expect(response.status).to eq(200)
expect(Invitee.exists?(invitee.id)).to eq(false)
end
it "destroys the invitee as the invitee owner" do
sign_in(invitee_user)
delete "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json"
expect(response.status).to eq(200)
expect(Invitee.exists?(invitee.id)).to eq(false)
end
it "returns 403 when user cannot act on invitee" do
lurker = Fabricate(:user)
sign_in(lurker)
delete "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json"
expect(response.status).to eq(403)
expect(Invitee.exists?(invitee.id)).to eq(true)
end
it "returns 404 when invitee does not exist" do
delete "/discourse-post-event/events/#{event.id}/invitees/999999.json"
expect(response.status).to eq(404)
end
end
end
describe "anonymous access to InviteesController" do
before do
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
end
fab!(:admin_user) { Fabricate(:user, admin: true) }
fab!(:topic) { Fabricate(:topic, user: admin_user) }
fab!(:post_1) { Fabricate(:post, user: admin_user, topic: topic) }
fab!(:event) { Fabricate(:event, post: post_1) }
fab!(:invitee_user, :user)
fab!(:invitee) do
DiscoursePostEvent::Invitee.create!(
post_id: post_1.id,
user_id: invitee_user.id,
status: DiscoursePostEvent::Invitee.statuses[:going],
)
end
it "requires login for create" do
post "/discourse-post-event/events/#{event.id}/invitees.json",
params: {
invitee: {
status: "going",
},
}
expect(response.status).to eq(403)
end
it "requires login for update" do
put "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json",
params: {
invitee: {
status: "interested",
},
}
expect(response.status).to eq(403)
end
it "requires login for destroy" do
delete "/discourse-post-event/events/#{event.id}/invitees/#{invitee.id}.json"
expect(response.status).to eq(403)
end
end
end