discourse/spec/requests/calendar_subscriptions_controller_spec.rb
Rafael dos Santos Silva cda4b34e2c
FEATURE: Add calendar subscription URLs to user preferences (#38598)
- Adds a **Calendar** tab to user preferences where users can generate
ICS subscription URLs for external calendar apps (Google Calendar, Apple
Calendar, Outlook, etc.)
- Core provides a **Bookmarked Reminders** feed (always available)
- When the discourse-calendar plugin is enabled, **All Events** and **My
Events** feeds are also shown
- URLs use scoped user API keys (read-only, ICS format only) with rate
limiting and scope validation
- Adds `include_interested` parameter to EventFinder so "My Events"
includes both going and interested events
- Adds `register_calendar_subscription_feed` plugin API for plugins to
register additional ICS feeds


Start State
<img width="1328" height="362" alt="image"
src="https://github.com/user-attachments/assets/60918591-7845-4cad-90c2-0493040d76c7"
/>

After clicking on Generate
<img width="1326" height="808" alt="image"
src="https://github.com/user-attachments/assets/aa3455fc-93c7-447b-8f82-16ef95580b37"
/>


After refreshing page
<img width="1323" height="415" alt="image"
src="https://github.com/user-attachments/assets/f22c02f8-02fd-49e5-b713-99dbba794037"
/>

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2026-03-17 10:28:20 -03:00

192 lines
5.4 KiB
Ruby

# frozen_string_literal: true
RSpec.describe CalendarSubscriptionsController do
fab!(:user)
describe "#show" do
it "requires login" do
get "/calendar-subscriptions.json"
expect(response.status).to eq(403)
end
it "returns has_subscription: false when no key exists" do
sign_in(user)
get "/calendar-subscriptions.json"
expect(response.status).to eq(200)
expect(response.parsed_body["has_subscription"]).to eq(false)
end
it "returns has_subscription: true when an active key exists" do
sign_in(user)
post "/calendar-subscriptions.json"
get "/calendar-subscriptions.json"
expect(response.status).to eq(200)
expect(response.parsed_body["has_subscription"]).to eq(true)
end
it "returns available feed names" do
sign_in(user)
get "/calendar-subscriptions.json"
expect(response.parsed_body["feeds"]).to include("bookmarks")
end
end
describe "#create" do
it "requires login" do
post "/calendar-subscriptions.json"
expect(response.status).to eq(403)
end
it "creates a key and returns bookmarks URL" do
sign_in(user)
post "/calendar-subscriptions.json"
expect(response.status).to eq(200)
body = response.parsed_body
expect(body["key"]).to be_present
expect(body["urls"]["bookmarks"]).to include("/u/#{user.username_lower}/bookmarks.ics")
expect(body["urls"]["bookmarks"]).to include("user_api_key=#{body["key"]}")
end
it "creates a UserApiKey with bookmarks_calendar scope" do
sign_in(user)
post "/calendar-subscriptions.json"
api_key =
UserApiKey.joins(:client).find_by(
user_id: user.id,
user_api_key_clients: {
client_id: CalendarSubscriptionsController::CLIENT_ID,
},
)
expect(api_key).to be_present
expect(api_key.scopes.map(&:name)).to include("bookmarks_calendar")
end
it "revokes existing key when creating a new one" do
sign_in(user)
post "/calendar-subscriptions.json"
first_key_hash =
UserApiKey
.joins(:client)
.find_by(
user_id: user.id,
user_api_key_clients: {
client_id: CalendarSubscriptionsController::CLIENT_ID,
},
)
.key_hash
post "/calendar-subscriptions.json"
old_key = UserApiKey.find_by(key_hash: first_key_hash)
expect(old_key.revoked_at).to be_present
new_key =
UserApiKey
.active
.joins(:client)
.find_by(
user_id: user.id,
user_api_key_clients: {
client_id: CalendarSubscriptionsController::CLIENT_ID,
},
)
expect(new_key).to be_present
expect(new_key.key_hash).not_to eq(first_key_hash)
end
context "with plugin feeds registered" do
let(:feed_entry) do
{
name: "test_feed",
scope: "bookmarks_calendar",
description_key: "test.description",
url: ->(base_url, _user, key) { "#{base_url}/test.ics?user_api_key=#{key}" },
}
end
let(:plugin) { Plugin::Instance.new }
before { DiscoursePluginRegistry.register_calendar_subscription_feed(feed_entry, plugin) }
after do
DiscoursePluginRegistry._raw_calendar_subscription_feeds.reject! do |h|
h[:value] == feed_entry
end
end
it "includes plugin feed URLs" do
sign_in(user)
post "/calendar-subscriptions.json"
body = response.parsed_body
expect(body["urls"]["test_feed"]).to include("/test.ics")
expect(body["urls"]["bookmarks"]).to be_present
end
end
context "with plugin feeds referencing unregistered scopes" do
let(:feed_entry) do
{
name: "bad_feed",
scope: "nonexistent_scope",
description_key: "test.description",
url: ->(base_url, _user, key) { "#{base_url}/test.ics?user_api_key=#{key}" },
}
end
let(:plugin) { Plugin::Instance.new }
before { DiscoursePluginRegistry.register_calendar_subscription_feed(feed_entry, plugin) }
after do
DiscoursePluginRegistry._raw_calendar_subscription_feeds.reject! do |h|
h[:value] == feed_entry
end
end
it "skips the unknown scope and still creates the key" do
sign_in(user)
post "/calendar-subscriptions.json"
expect(response.status).to eq(200)
api_key =
UserApiKey.joins(:client).find_by(
user_id: user.id,
user_api_key_clients: {
client_id: CalendarSubscriptionsController::CLIENT_ID,
},
)
expect(api_key.scopes.map(&:name)).to eq(["bookmarks_calendar"])
end
end
end
describe "#destroy" do
it "requires login" do
delete "/calendar-subscriptions.json"
expect(response.status).to eq(403)
end
it "revokes the existing subscription key" do
sign_in(user)
post "/calendar-subscriptions.json"
expect(response.status).to eq(200)
delete "/calendar-subscriptions.json"
expect(response.status).to eq(204)
get "/calendar-subscriptions.json"
expect(response.parsed_body["has_subscription"]).to eq(false)
end
it "succeeds even when no key exists" do
sign_in(user)
delete "/calendar-subscriptions.json"
expect(response.status).to eq(204)
end
end
end