0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-12 03:37:13 +08:00
discourse/plugins/discourse-calendar/spec/models/discourse_post_event/event_spec.rb
Kris 0cf23c2854
FEATURE: Add a livestream_allowed_hosts site setting for livestreams (#42374)
Currently the hosts that can be used for a livestream event are
hardcoded in the composer's JavaScript, and the server accepts any https
location, so the list isn't really enforced... and there's no way to use
hosts that would otherwise work, including custom or self-hosted
sources.

This adds a `livestream_allowed_hosts` site setting (defaulting to the
existing hardcoded list) and validates against it both in the composer
and on the server. Whether a livestream embeds a player still depends on
the site's onebox settings.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
2026-08-06 14:49:38 -04:00

1531 lines
48 KiB
Ruby
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
describe DiscoursePostEvent::Event do
before do
freeze_time DateTime.parse("2020-04-24 14:10")
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
end
it do
is_expected.to validate_length_of(:description).is_at_most(
DiscoursePostEvent::Event::MAX_DESCRIPTION_LENGTH,
)
end
it do
is_expected.to validate_length_of(:name).is_at_least(
DiscoursePostEvent::Event::MIN_NAME_LENGTH,
).is_at_most(DiscoursePostEvent::Event::MAX_NAME_LENGTH)
end
describe "#warm_livestream_onebox" do
let(:livestream_url) { "https://www.youtube.com/live/abc123" }
fab!(:topic) { Fabricate(:topic, category: nil) }
fab!(:post) { Fabricate(:post, topic: topic) }
before { Jobs.run_later! }
it "enqueues onebox warming for a livestream URL" do
expect_enqueued_with(
job: :warm_livestream_onebox,
args: {
event_id: post.id,
url: livestream_url,
},
) { Fabricate(:event, post: post, livestream: true, location: livestream_url) }
end
it "skips onebox warming when the onebox is cached" do
Discourse.cache.write(
Oneboxer.onebox_cache_key(livestream_url),
{ onebox: "<aside>cached</aside>" },
)
expect_not_enqueued_with(job: :warm_livestream_onebox) do
Fabricate(:event, post: post, livestream: true, location: livestream_url)
end
end
it "skips onebox warming for events that are not livestreams" do
expect_not_enqueued_with(job: :warm_livestream_onebox) do
Fabricate(:event, post: post, livestream: false, location: "Room 5")
end
end
it "skips onebox warming when the livestream has no URL" do
expect_not_enqueued_with(job: :warm_livestream_onebox) do
Fabricate(:event, post: post, livestream: true)
end
end
it "warms the onebox when the URL changes" do
event = Fabricate(:event, post: post, livestream: true, location: livestream_url)
expect_enqueued_with(
job: :warm_livestream_onebox,
args: {
event_id: event.id,
url: "https://www.youtube.com/live/def456",
},
) { event.update!(location: "https://www.youtube.com/live/def456") }
end
it "does not warm the onebox when an unrelated attribute changes" do
event = Fabricate(:event, post: post, livestream: true, location: livestream_url)
expect_not_enqueued_with(job: :warm_livestream_onebox) { event.update!(name: "Renamed") }
end
end
describe "#livestream_url" do
fab!(:topic) { Fabricate(:topic, category: nil) }
fab!(:post) { Fabricate(:post, topic: topic) }
it "prefers the location over the url" do
event =
Fabricate(
:event,
post: post,
location: "https://zoom.us/j/123456789",
url: "https://example.com/fallback",
)
expect(event.livestream_url).to eq("https://zoom.us/j/123456789")
end
it "falls back to the url when there is no location" do
event = Fabricate(:event, post: post, url: "https://example.com/fallback")
expect(event.livestream_url).to eq("https://example.com/fallback")
end
it "is nil when neither is set" do
event = Fabricate(:event, post: post)
expect(event.livestream_url).to be_nil
end
end
describe "#is_zoom_livestream?" do
fab!(:topic) { Fabricate(:topic, category: nil) }
fab!(:post) { Fabricate(:post, topic: topic) }
before do
SiteSetting.livestream_zoom_enabled = true
# Creating a livestream event enqueues onebox warming, which would
# otherwise run inline and try to fetch the URL.
Jobs.run_later!
end
def event_for(location)
Fabricate(:event, post: post, livestream: true, location: location)
end
it "recognises a Zoom URL on a vanity subdomain" do
expect(event_for("https://us06web.zoom.us/j/123456789?pwd=secret")).to be_is_zoom_livestream
end
it "recognises a Zoom URL on the bare host" do
expect(event_for("https://zoom.us/j/123456789")).to be_is_zoom_livestream
end
it "recognises a Zoom webinar URL" do
expect(event_for("https://zoom.us/w/123456789")).to be_is_zoom_livestream
end
it "rejects a host that only ends in the Zoom domain" do
expect(event_for("https://notzoom.us/j/123456789")).not_to be_is_zoom_livestream
end
it "rejects a Zoom URL with no joinable meeting number" do
expect(event_for("https://zoom.us/about")).not_to be_is_zoom_livestream
end
it "rejects a non-Zoom livestream URL" do
expect(event_for("https://www.youtube.com/live/abc123")).not_to be_is_zoom_livestream
end
it "is false when the event is not a livestream" do
event = Fabricate(:event, post: post, livestream: false, url: "https://zoom.us/j/123456789")
expect(event).not_to be_is_zoom_livestream
end
it "is false when Zoom is disabled" do
SiteSetting.livestream_zoom_enabled = false
expect(event_for("https://zoom.us/j/123456789")).not_to be_is_zoom_livestream
end
end
describe "#create_livestream_chat_channel" do
fab!(:category)
fab!(:topic) { Fabricate(:topic, category: category) }
fab!(:post) { Fabricate(:post, topic: topic) }
before do
SiteSetting.chat_enabled = true
Jobs.run_later!
end
it "creates the chat channel after the livestream event is committed" do
expect {
Fabricate(
:event,
post: post,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
}.to change(DiscourseCalendar::Livestream::TopicChatChannel, :count).by(1)
expect(post.topic.topic_chat_channel.chat_channel.chatable).to eq(category)
end
it "does not create a chat channel when chat is disabled" do
SiteSetting.chat_enabled = false
expect {
Fabricate(
:event,
post: post,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
}.not_to change(DiscourseCalendar::Livestream::TopicChatChannel, :count)
end
it "does not create a chat channel for a livestream event on a reply" do
reply = Fabricate(:post, topic: topic)
expect {
Fabricate(
:event,
post: reply,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
}.not_to change(DiscourseCalendar::Livestream::TopicChatChannel, :count)
end
end
describe "#reset_invalid_livestream" do
fab!(:post)
# enqueue (don't run) the onebox-warming job so it doesn't make a real request
before { Jobs.run_later! }
it "keeps livestream enabled for an allowed host" do
event =
Fabricate(
:event,
post: post,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
expect(event.reload.livestream).to eq(true)
end
it "keeps livestream enabled for a host an admin added" do
SiteSetting.livestream_allowed_hosts = "stream.example.com"
event =
Fabricate(:event, post: post, livestream: true, location: "https://stream.example.com/live")
expect(event.reload.livestream).to eq(true)
end
it "resets livestream when the location is not an https URL" do
event = Fabricate(:event, post: post, livestream: true, location: "Room 5")
expect(event.reload.livestream).to eq(false)
event.update!(location: "http://www.youtube.com/live/abc123")
expect(event.reload.livestream).to eq(false)
end
it "falls back to the url, which is what the onebox and serializers use" do
event =
Fabricate(
:event,
post: post,
livestream: true,
location: nil,
url: "https://www.youtube.com/live/abc123",
)
expect(event.reload.livestream).to eq(true)
event.update!(url: "https://example.com/live")
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when the location host is not allowed" do
event = Fabricate(:event, post: post, livestream: true, location: "https://example.com/live")
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when the location only resembles an allowed host" do
event =
Fabricate(:event, post: post, livestream: true, location: "https://youtube.com.evil.com/x")
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when an admin removes the host from the allowlist" do
event =
Fabricate(
:event,
post: post,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
SiteSetting.livestream_allowed_hosts = "zoom.us"
event.update!(name: "Renamed")
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when there is no location" do
event = Fabricate(:event, post: post, livestream: true)
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when the location is edited away from a URL" do
event =
Fabricate(
:event,
post: post,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
event.update!(location: "Room 5")
expect(event.reload.livestream).to eq(false)
end
it "resets livestream when the event is not on the first post" do
reply = Fabricate(:post, topic: post.topic)
expect(reply.is_first_post?).to be(false)
event =
Fabricate(
:event,
post: reply,
livestream: true,
location: "https://www.youtube.com/live/abc123",
)
expect(event.reload.livestream).to eq(false)
end
end
describe "#raw_invitees_are_groups" do
fab!(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post) { Fabricate(:post, topic: topic) }
it "is invalid when an invitee matches a username that is not a group" do
Fabricate(:user, username: "not_a_group")
event =
Fabricate.build(
:event,
post: post,
original_starts_at: Time.now,
raw_invitees: ["not_a_group"],
)
expect(event).not_to be_valid
expect(event.errors[:base]).to include(
I18n.t("discourse_post_event.errors.models.event.raw_invitees.only_group"),
)
end
it "is valid when an invitee is a group whose name collides with a username" do
Fabricate(:user).update_columns(
username: DiscoursePostEvent::Event::PUBLIC_GROUP,
username_lower: DiscoursePostEvent::Event::PUBLIC_GROUP,
)
event =
Fabricate.build(
:event,
post: post,
original_starts_at: Time.now,
raw_invitees: [DiscoursePostEvent::Event::PUBLIC_GROUP],
)
expect(event).to be_valid
end
end
describe "topic custom fields callback" do
let(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
let!(:notified_user) { Fabricate(:user) }
let(:topic) { Fabricate(:topic, user: user) }
let!(:first_post) { Fabricate(:post, topic: topic) }
let(:second_post) { Fabricate(:post, topic: topic) }
let!(:starts_at) { Time.zone.parse("2020-04-24 14:15:00") }
let!(:ends_at) { Time.zone.parse("2020-04-24 16:15:00") }
let!(:alt_starts_at) { Time.zone.parse("2020-04-24 14:14:25") }
let!(:alt_ends_at) { Time.zone.parse("2020-04-24 19:15:25") }
let(:event) do
DiscoursePostEvent::Event.create!(
id: first_post.id,
original_starts_at: Time.now + 1.hour,
original_ends_at: Time.now + 2.hours,
)
end
let(:late_event) do
DiscoursePostEvent::Event.create!(
id: first_post.id,
original_starts_at: Time.now - 10.hours,
original_ends_at: Time.now - 8.hours,
)
end
let(:first_post_starts_at) do
Time.zone.parse(
first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
)
end
let(:first_post_ends_at) do
Time.zone.parse(first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT])
end
describe "#after_commit[:create, :update]" do
context "when a post event has been created" do
context "when the associated post is the OP" do
it "sets the topic custom field and creates event date" do
expect(first_post.is_first_post?).to be(true)
expect(first_post.topic.custom_fields).to be_blank
expect {
DiscoursePostEvent::Event.create!(
id: first_post.id,
original_starts_at: starts_at,
original_ends_at: ends_at,
)
}.to change { DiscoursePostEvent::EventDate.count }
first_post.topic.reload
expect(first_post_starts_at).to eq_time(starts_at)
expect(first_post_ends_at).to eq_time(ends_at)
expect(DiscoursePostEvent::EventDate.last.starts_at).to eq_time(starts_at)
expect(DiscoursePostEvent::EventDate.last.ends_at).to eq_time(ends_at)
end
end
context "when the associated post is not the OP" do
it "doesnt set the topic custom field but still creates event date" do
expect(second_post.is_first_post?).to be(false)
expect(second_post.topic.custom_fields).to be_blank
expect {
DiscoursePostEvent::Event.create!(id: second_post.id, original_starts_at: starts_at)
}.to change { DiscoursePostEvent::EventDate.count }
second_post.topic.reload
expect(second_post.topic.custom_fields).to be_blank
end
end
describe "notify an user" do
describe "before the event starts" do
it "does notify the user" do
expect { event.create_notification!(notified_user, first_post) }.to change {
Notification.count
}.by(1)
end
end
describe "after the event starts" do
it "doesn't notify the user" do
expect { late_event.create_notification!(notified_user, first_post) }.not_to change {
Notification.count
}
end
end
describe "with private message topics" do
let(:pm_owner) { Fabricate(:user) }
let(:allowed_user) { Fabricate(:user) }
let(:disallowed_user) { Fabricate(:user) }
let(:pm_topic) do
Fabricate(:private_message_topic, user: pm_owner, recipient: allowed_user)
end
let(:pm_post) { Fabricate(:post, topic: pm_topic, user: pm_owner) }
let(:pm_event) { Fabricate(:event, post: pm_post) }
it "does not send notifications to users without access to the PM" do
expect(Guardian.new(disallowed_user).can_see?(pm_topic)).to be(false)
expect { pm_event.create_notification!(disallowed_user, pm_post) }.not_to change {
Notification.count
}
end
it "does send notifications to users with access to the PM" do
expect(Guardian.new(allowed_user).can_see?(pm_topic)).to be(true)
expect { pm_event.create_notification!(allowed_user, pm_post) }.to change {
Notification.count
}.by(1)
end
end
end
end
context "when a post event has been updated" do
context "when the associated post is the OP" do
let!(:post_event) do
Fabricate(
:event,
post: first_post,
original_starts_at: starts_at,
original_ends_at: ends_at,
)
end
it "sets the topic custom field" do
first_post.topic.reload
expect(first_post.is_first_post?).to be(true)
expect(first_post_starts_at).to eq_time(starts_at)
expect(first_post_ends_at).to eq_time(ends_at)
first_event_date = post_event.event_dates.last
expect(first_event_date.starts_at).to eq_time(starts_at)
expect(first_event_date.finished_at).to be nil
post_event.update_with_params!(
original_starts_at: alt_starts_at,
original_ends_at: alt_ends_at,
)
first_post.topic.reload
first_event_date.reload
second_event_date = post_event.event_dates.last
expect(
Time.zone.parse(
first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
),
).to eq(alt_starts_at)
expect(
Time.zone.parse(
first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT],
),
).to eq(alt_ends_at)
expect(first_event_date.finished_at).not_to be nil
expect(second_event_date.starts_at).to eq_time(alt_starts_at)
second_event_date.update_columns(finished_at: Time.current)
expect(post_event.starts_at).to eq_time(alt_starts_at)
expect(post_event.ends_at).to eq_time(alt_ends_at)
end
end
context "when the associated post is not the OP" do
let(:post_event) { Fabricate(:event, post: second_post, original_starts_at: starts_at) }
it "doesnt set the topic custom field" do
expect(second_post.is_first_post?).to be(false)
expect(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
).to be_blank
post_event.update_with_params!(original_starts_at: alt_starts_at)
second_post.topic.reload
expect(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
).to be_blank
second_event_date = post_event.event_dates.last
expect(second_event_date.starts_at).to eq_time(alt_starts_at)
end
end
end
end
describe "#after_commit[:destroy]" do
context "when a post event has been destroyed" do
context "when the associated post is the OP" do
let!(:post_event) do
Fabricate(
:event,
post: first_post,
original_starts_at: starts_at,
original_ends_at: ends_at,
)
end
it "sets the topic custom field" do
first_post.topic.reload
expect(first_post.is_first_post?).to be(true)
expect(first_post_starts_at).to eq_time(starts_at)
expect(first_post_ends_at).to eq_time(ends_at)
post_event.destroy!
first_post.topic.reload
expect(
first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
).to be_blank
expect(
first_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT],
).to be_blank
end
end
context "when the associated post is not the OP" do
let!(:first_post_event) do
Fabricate(
:event,
post: first_post,
original_starts_at: starts_at,
original_ends_at: ends_at,
)
end
let!(:second_post_event) do
Fabricate(
:event,
post: second_post,
original_starts_at: starts_at,
original_ends_at: ends_at,
)
end
it "doesnt change the topic custom field" do
second_post.topic.reload
expect(first_post.is_first_post?).to be(true)
expect(
Time.zone.parse(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
),
).to eq(starts_at)
expect(
Time.zone.parse(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT],
),
).to eq(ends_at)
expect(second_post.is_first_post?).to be(false)
second_post_event.destroy!
second_post.topic.reload
expect(
Time.zone.parse(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT],
),
).to eq(starts_at)
expect(
Time.zone.parse(
second_post.topic.custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT],
),
).to eq(ends_at)
end
end
end
end
end
describe "#ongoing?" do
let(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
let(:topic) { Fabricate(:topic, user: user) }
let!(:first_post) { Fabricate(:post, topic: topic) }
context "with ends_at" do
context "with starts_at < current date" do
context "with ends_at < current date" do
it "is ongoing" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: 2.hours.ago,
original_ends_at: 1.hour.ago,
post: first_post,
)
expect(post_event.ongoing?).to be(false)
end
end
context "with ends_at > current date" do
it "is not ongoing" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: 2.hours.ago,
original_ends_at: 3.hours.from_now,
post: first_post,
)
expect(post_event.ongoing?).to be(true)
end
end
end
context "when starts_at > current date" do
context "when ends_at > current date" do
it "is not ongoing" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: 1.hour.from_now,
original_ends_at: 2.hours.from_now,
post: first_post,
)
expect(post_event.ongoing?).to be(false)
end
end
end
end
context "without ends_at date" do
context "when starts_at < current date" do
it "is ongoing" do
post_event =
DiscoursePostEvent::Event.create!(original_starts_at: 2.hours.ago, post: first_post)
expect(post_event.ongoing?).to be(true)
end
end
context "when starts_at == current date" do
it "is ongoing" do
post_event =
DiscoursePostEvent::Event.create!(original_starts_at: Time.now, post: first_post)
expect(post_event.ongoing?).to be(true)
end
end
context "when starts_at > current date" do
it "is not ongoing" do
post_event =
DiscoursePostEvent::Event.create!(original_starts_at: 1.hour.from_now, post: first_post)
expect(post_event.ongoing?).to be(false)
end
end
end
end
describe "#currently_within_event_timeframe?" do
let(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
let(:topic) { Fabricate(:topic, user: user) }
let!(:first_post) { Fabricate(:post, topic: topic) }
def event_with(starts_at:, ends_at: nil, all_day: false)
DiscoursePostEvent::Event.create!(
original_starts_at: starts_at,
original_ends_at: ends_at,
all_day:,
post: first_post,
)
end
it "is false before the early access window opens" do
expect(event_with(starts_at: 45.minutes.from_now).currently_within_event_timeframe?).to be(
false,
)
end
it "is true within the early access window" do
expect(event_with(starts_at: 15.minutes.from_now).currently_within_event_timeframe?).to be(
true,
)
end
it "is true while the event is in progress" do
expect(
event_with(
starts_at: 1.hour.ago,
ends_at: 1.hour.from_now,
).currently_within_event_timeframe?,
).to be(true)
end
it "is true within the grace period after the end" do
expect(
event_with(
starts_at: 2.hours.ago,
ends_at: 5.minutes.ago,
).currently_within_event_timeframe?,
).to be(true)
end
it "is false once the grace period has passed" do
expect(
event_with(
starts_at: 2.hours.ago,
ends_at: 15.minutes.ago,
).currently_within_event_timeframe?,
).to be(false)
end
it "is true for a started event without an end time" do
expect(event_with(starts_at: 1.day.ago).currently_within_event_timeframe?).to be(true)
end
context "for an all day event" do
it "is true when now is between the start + end of the day of the event" do
expect(
event_with(
starts_at: Time.zone.now.beginning_of_day,
all_day: true,
).currently_within_event_timeframe?,
).to be(true)
end
it "is false when now is before the start of the day of the event" do
expect(
event_with(
starts_at: Time.zone.now.tomorrow.beginning_of_day,
all_day: true,
).currently_within_event_timeframe?,
).to be(false)
end
end
end
describe "#expired?" do
let(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
let(:topic) { Fabricate(:topic, user: user) }
let!(:first_post) { Fabricate(:post, topic: topic) }
context "with ends_at" do
context "when starts_at < current date" do
context "when ends_at < current date" do
it "is expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-22 14:05"),
original_ends_at: DateTime.parse("2020-04-23 14:05"),
post: first_post,
)
expect(post_event.expired?).to be(true)
end
end
context "when ends_at > current date" do
it "is not expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-24 14:15"),
original_ends_at: DateTime.parse("2020-04-25 11:05"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
end
context "when starts_at > current date" do
it "is not expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-25 14:05"),
original_ends_at: DateTime.parse("2020-04-26 14:05"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
end
context "without ends_at date" do
context "when starts_at < current date" do
it "is expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-24 14:05"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
context "when starts_at == current date" do
it "is expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-24 14:10"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
context "when starts_at > current date" do
it "is not expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-24 14:15"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
end
context "with recurring events" do
context "with recurrence_until set" do
context "when current date is before recurrence_until" do
it "is not expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-22 14:05"),
original_ends_at: DateTime.parse("2020-04-22 15:05"),
recurrence: "FREQ=WEEKLY",
recurrence_until: DateTime.parse("2020-05-01 00:00"),
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
context "when current date is after recurrence_until" do
it "is expired" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-22 14:05"),
original_ends_at: DateTime.parse("2020-04-22 15:05"),
recurrence: "FREQ=WEEKLY",
recurrence_until: DateTime.parse("2020-04-23 00:00"),
post: first_post,
)
expect(post_event.expired?).to be(true)
end
end
context "when current date equals recurrence_until" do
it "is not expired" do
current_time = DateTime.parse("2020-04-24 14:10")
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-22 14:05"),
original_ends_at: DateTime.parse("2020-04-22 15:05"),
recurrence: "FREQ=WEEKLY",
recurrence_until: current_time,
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
end
context "without recurrence_until set" do
it "never expires" do
post_event =
DiscoursePostEvent::Event.create!(
original_starts_at: DateTime.parse("2020-04-22 14:05"),
original_ends_at: DateTime.parse("2020-04-22 15:05"),
recurrence: "FREQ=WEEKLY",
recurrence_until: nil,
post: first_post,
)
expect(post_event.expired?).to be(false)
end
end
end
end
describe "#duration" do
let!(:post_1) { Fabricate(:post) }
context "when event has both starts_at and ends_at" do
it "returns duration in HH:MM:SS format" do
event =
DiscoursePostEvent::Event.create!(
id: post_1.id,
original_starts_at: "2022-01-15 10:00:00 UTC",
original_ends_at: "2022-01-15 11:30:00 UTC",
)
expect(event.duration).to eq("01:30:00")
end
end
context "when event only has starts_at" do
it "returns default duration of 1 hour" do
event =
DiscoursePostEvent::Event.create!(
id: post_1.id,
original_starts_at: "2022-01-15 10:00:00 UTC",
)
expect(event.duration).to eq("01:00:00")
end
end
context "when event spans multiple days" do
it "returns correct duration" do
event =
DiscoursePostEvent::Event.create!(
id: post_1.id,
original_starts_at: "2022-01-15 10:00:00 UTC",
original_ends_at: "2022-01-16 12:30:00 UTC",
)
expect(event.duration).to eq("26:30:00")
end
end
end
describe "#update_with_params!" do
let!(:post_1) { Fabricate(:post) }
let!(:user_1) { Fabricate(:user) }
let(:group_1) do
Fabricate(:group).tap do |g|
g.add(user_1)
g.save!
end
end
before { freeze_time }
context "with a private event" do
let!(:event_1) do
Fabricate(
:event,
post: post_1,
status: DiscoursePostEvent::Event.statuses[:private],
raw_invitees: [group_1.name],
)
end
before do
freeze_time
event_1.create_invitees([{ user_id: user_1.id, status: 0 }])
end
context "when updating the name" do
it "doesnt clear existing invitees" do
expect(event_1.invitees.count).to eq(1)
expect { event_1.update_with_params!(name: "The event") }.not_to change {
event_1.invitees.count
}
end
end
context "when an invitee is no longer allowed" do
let(:group_2) { Fabricate(:group) }
it "resets the pruned invitee's topic tracking" do
event_1.invitees.find_by(user_id: user_1.id).update_attendance!(:going)
expect(TopicUser.find_by(user: user_1, topic: post_1.topic).notification_level).to eq(
TopicUser.notification_levels[:watching],
)
event_1.update_with_params!(raw_invitees: [group_2.name])
expect(event_1.invitees.find_by(user_id: user_1.id)).to be_nil
expect(TopicUser.find_by(user: user_1, topic: post_1.topic).notification_level).to eq(
TopicUser.notification_levels[:regular],
)
end
it "unfollows the pruned invitee from the livestream chat channel" do
channel = Fabricate(:category_channel)
Fabricate(:topic_chat_channel, topic: post_1.topic, chat_channel: channel)
membership =
Fabricate(
:user_chat_channel_membership,
user: user_1,
chat_channel: channel,
following: true,
)
event_1.update_with_params!(raw_invitees: [group_2.name])
expect(event_1.invitees.find_by(user_id: user_1.id)).to be_nil
expect(membership.reload.following).to eq(false)
end
end
end
end
describe "resetting topic tracking when the event is destroyed" do
fab!(:user_1, :user)
fab!(:topic_1, :topic)
fab!(:post_1) { Fabricate(:post, topic: topic_1) }
fab!(:event_1) { Fabricate(:event, post: post_1) }
before { event_1.create_invitees([{ user_id: user_1.id, status: 0 }]) }
it "resets a watching invitee back to regular, leaving the topic intact" do
event_1.invitees.find_by(user_id: user_1.id).update_attendance!(:going)
expect(TopicUser.find_by(user: user_1, topic: topic_1).notification_level).to eq(
TopicUser.notification_levels[:watching],
)
event_1.destroy!
expect(Topic.exists?(topic_1.id)).to eq(true)
expect(TopicUser.find_by(user: user_1, topic: topic_1).notification_level).to eq(
TopicUser.notification_levels[:regular],
)
end
end
describe "#missing_users" do
let!(:post_1) { Fabricate(:post) }
let!(:user_1) { Fabricate(:user) }
let!(:user_2) { Fabricate(:user) }
let!(:user_3) { Fabricate(:user) }
let!(:group_1) do
Fabricate(:group).tap do |g|
g.add(user_1)
g.add(user_2)
g.add(user_3)
g.save!
end
end
let!(:group_2) do
Fabricate(:group).tap do |g|
g.add(user_2)
g.save!
end
end
let!(:event_1) do
Fabricate(
:event,
post: post_1,
status: DiscoursePostEvent::Event.statuses[:private],
raw_invitees: [group_1.name, group_2.name],
)
end
before { DiscoursePostEvent::Invitee.create_attendance!(user_3.id, post_1.id, :going) }
it "doesn't return already attending user" do
expect(event_1.missing_users.pluck(:id)).to_not include(user_3.id)
end
it "return users from groups with no duplicates" do
expect(event_1.missing_users.pluck(:id)).to match_array([user_1.id, user_2.id])
end
context "with private event with empty raw_invitees" do
let!(:event_without_invitees) do
Fabricate(
:event,
post: Fabricate(:post),
status: DiscoursePostEvent::Event.statuses[:private],
raw_invitees: [],
)
end
it "does not return all site users" do
expect(event_without_invitees.missing_users.count).to eq(0)
expect(User.real.activated.not_silenced.not_suspended.not_staged.count).not_to eq(0)
end
end
end
describe "#calculate_next_date" do
subject(:next_date) { event.calculate_next_date }
context "when the event is recurring" do
context "when the recurring ends on the next day" do
let(:event) do
Fabricate(
:event,
recurrence: "every_day",
recurrence_until: "2020-04-25 23:59",
original_starts_at: "2020-04-20 13:00",
)
end
it "returns the next occurrence within the recurrence period" do
expect(next_date).not_to be_blank
expect(next_date).to be_an(Array)
expect(next_date.length).to eq(2)
expect(next_date[0]).to eq(Time.utc(2020, 4, 25, 13, 0, 0))
expect(next_date[1]).to eq(Time.utc(2020, 4, 25, 14, 0, 0))
end
end
end
end
describe "#starts_at and #ends_at for expired recurring events" do
context "when recurring event has expired (past recurrence_until)" do
let(:expired_recurring_event) do
event =
Fabricate(
:event,
recurrence: "every_week",
recurrence_until: 1.day.ago,
original_starts_at: 1.week.ago,
original_ends_at: 1.week.ago + 2.hours,
)
event
end
it "returns nil for starts_at since no future dates can be computed" do
expect(expired_recurring_event.starts_at).to be_nil
end
it "returns nil for ends_at since no future dates can be computed" do
expect(expired_recurring_event.ends_at).to be_nil
end
it "serializer handles nil starts_at correctly" do
serializer =
DiscoursePostEvent::EventSerializer.new(
expired_recurring_event,
scope: Guardian.new,
root: false,
)
json = JSON.parse(serializer.to_json)
expect(json["starts_at"]).to be_nil
expect(json["ends_at"]).to be_nil
end
it "basic serializer handles expired recurring events correctly" do
serializer =
DiscoursePostEvent::BasicEventSerializer.new(
expired_recurring_event,
root: false,
scope: Guardian.new,
)
json = JSON.parse(serializer.to_json)
expect(json["starts_at"]).to be_nil
expect(json["ends_at"]).to be_nil
end
end
context "when recurring event has no recurrence_until (endless)" do
let(:endless_recurring_event) do
Fabricate(
:event,
recurrence: "every_week",
recurrence_until: nil,
original_starts_at: 1.week.ago,
original_ends_at: 1.week.ago + 2.hours,
)
end
it "still returns starts_at from event_dates" do
expect(endless_recurring_event.starts_at).not_to be_nil
end
it "still returns ends_at from event_dates" do
expect(endless_recurring_event.starts_at).not_to be_nil
end
end
context "when non-recurring event" do
let(:non_recurring_event) do
Fabricate(
:event,
recurrence: nil,
original_starts_at: 1.week.ago,
original_ends_at: 1.week.ago + 2.hours,
)
end
it "still returns starts_at from event_dates regardless of when it was" do
expect(non_recurring_event.starts_at).not_to be_nil
end
it "still returns ends_at from event_dates regardless of when it was" do
expect(non_recurring_event.ends_at).not_to be_nil
end
end
end
describe "syncing from raw" do
fab!(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post) { Fabricate(:post, topic: topic, user: user) }
fab!(:upload)
it "sets livestream from the bbcode attribute" do
Jobs.run_later!
post.update!(
raw:
"[event start=\"2020-04-24 14:15\" livestream=\"true\" location=\"https://www.youtube.com/live/abc123\"]\n[/event]",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
expect(post.event.livestream).to eq(true)
end
it "defaults livestream to false when the attribute is absent" do
post.update!(raw: "[event start=\"2020-04-24 14:15\"]\n[/event]")
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
expect(post.event.livestream).to eq(false)
end
it "ignores livestream=true when the location is not an http(s) URL" do
post.update!(
raw: "[event start=\"2020-04-24 14:15\" livestream=\"true\" location=\"Room 5\"]\n[/event]",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
expect(post.event.livestream).to eq(false)
end
context "with image" do
before do
post.update!(
raw: "[event start=\"2020-04-24 14:15\" image=\"#{upload.short_url}\"]\n[/event]",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
end
it "resolves image to image_upload_id and creates UploadReference" do
expect(post.event.image_upload_id).to eq(upload.id)
expect(UploadReference.exists?(upload_id: upload.id, target: post.event)).to eq(true)
end
it "clears image_upload_id when image is removed" do
post.update!(raw: "[event start=\"2020-04-24 14:15\"]\n[/event]")
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
expect(post.event.image_upload_id).to be_nil
expect(UploadReference.exists?(upload_id: upload.id, target: post.event)).to eq(false)
end
end
describe "post.image_upload_id sync on post_process_cooked" do
fab!(:topic) { Fabricate(:topic, user: user) }
it "sets post.image_upload_id from the event image after post processing" do
post =
Fabricate(
:post,
topic: topic,
user: user,
raw: "[event start=\"2020-04-24 14:15\" image=\"#{upload.short_url}\"]\n[/event]",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
CookedPostProcessor.new(post).post_process
post.reload
expect(post.image_upload_id).to eq(upload.id)
end
it "sets topic.image_upload_id when the post is the first post" do
post = topic.first_post || Fabricate(:post, topic: topic, user: user, post_number: 1)
post.update!(
raw: "[event start=\"2020-04-24 14:15\" image=\"#{upload.short_url}\"]\n[/event]",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
CookedPostProcessor.new(post).post_process
post.reload
topic.reload
expect(topic.image_upload_id).to eq(upload.id)
end
it "does not override post.image_upload_id when event has no image" do
other_upload = Fabricate(:upload)
post =
Fabricate(
:post,
topic: topic,
user: user,
raw: "[event start=\"2020-04-24 14:15\"]\n[/event]\n![image](#{other_upload.url})",
)
post.rebake!
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
CookedPostProcessor.new(post).post_process
post.reload
expect(post.image_upload_id).not_to eq(nil)
expect(post.event.image_upload_id).to be_nil
end
end
context "with an image from a different secure post" do
fab!(:private_upload_owner, :user)
fab!(:private_upload_recipient, :user)
fab!(:private_upload_post) do
Fabricate(
:private_message_post,
user: private_upload_owner,
recipient: private_upload_recipient,
)
end
fab!(:private_upload) do
Fabricate(
:secure_upload,
user: private_upload_owner,
access_control_post: private_upload_post,
)
end
before do
setup_s3
SiteSetting.secure_uploads = true
post.update!(
raw: "[event start=\"2020-04-24 14:15\" image=\"#{private_upload.url}\"]\n[/event]",
)
post.rebake!
end
it "does not associate the upload" do
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
post.reload
expect(post.event.image_upload_id).to be_nil
expect(UploadReference.exists?(upload_id: private_upload.id, target: post.event)).to eq(
false,
)
end
end
end
describe "post/topic image_upload_id sync on post_edited" do
fab!(:user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post) { Fabricate(:post, topic: topic, user: user, post_number: 1) }
fab!(:upload)
it "syncs event image to post and topic when image is added" do
PostRevisor.new(post).revise!(
user,
raw: "[event start=\"2020-04-24 14:15\" image=\"#{upload.short_url}\"]\n[/event]",
)
post.reload
topic.reload
expect(post.image_upload_id).to eq(upload.id)
expect(topic.image_upload_id).to eq(upload.id)
end
it "does not clear post image_upload_id when event has no image but post has inline images" do
other_upload = Fabricate(:upload)
PostRevisor.new(post).revise!(
user,
raw: "[event start=\"2020-04-24 14:15\"]\n[/event]\n![image](#{other_upload.short_url})",
)
CookedPostProcessor.new(post).post_process
post.reload
PostRevisor.new(post).revise!(
user,
raw:
"[event start=\"2020-04-24 14:15\"]\n[/event]\n![image](#{other_upload.short_url})\nupdated text",
)
post.reload
expect(post.image_upload_id).to eq(other_upload.id)
end
end
end
describe DiscoursePostEvent::Event, "#most_likely_going" do
before do
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
end
fab!(:event)
it "orders going invitees by RSVP time (created_at), not by user id" do
early_rsvp = Fabricate(:user)
late_rsvp = Fabricate(:user)
# later-created user (higher id) RSVP'd first, so should come first
expect(late_rsvp.id).to be > early_rsvp.id
Fabricate(
:post_event_invitee,
event:,
user: late_rsvp,
status: DiscoursePostEvent::Invitee.statuses[:going],
created_at: 2.hours.ago,
)
Fabricate(
:post_event_invitee,
event:,
user: early_rsvp,
status: DiscoursePostEvent::Invitee.statuses[:going],
created_at: 1.hour.ago,
)
expect(event.most_likely_going.map(&:user)).to eq([late_rsvp, early_rsvp])
end
it "groups going invitees ahead of interested ones regardless of RSVP time" do
interested = Fabricate(:user)
going = Fabricate(:user)
Fabricate(
:post_event_invitee,
event:,
user: interested,
status: DiscoursePostEvent::Invitee.statuses[:interested],
created_at: 2.hours.ago,
)
Fabricate(
:post_event_invitee,
event:,
user: going,
status: DiscoursePostEvent::Invitee.statuses[:going],
created_at: 1.hour.ago,
)
expect(event.most_likely_going.map(&:user)).to eq([going, interested])
end
end
describe DiscoursePostEvent::Event, "#capacity" do
before do
Jobs.run_immediately!
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
end
it "detects capacity when max_attendees set" do
creator = Fabricate(:user)
topic = Fabricate(:topic, user: creator)
post = Fabricate(:post, user: creator, topic: topic)
event = Fabricate(:event, post: post, max_attendees: 1)
event.create_invitees(
[{ user_id: creator.id, status: DiscoursePostEvent::Invitee.statuses[:going] }],
)
expect(event.at_capacity?).to eq(true)
end
end