Add JSON activities API endpoint (#2783)

* Add JSON events API endpoint

* Add regression test for event particulars defaults

* Move JSON events API to a dedicated ActivitiesController

The events endpoint served both the HTML day timeline and the JSON API
feed, but the two paths shared no data or behavior — the HTML side uses
DayTimelinesScoped while the JSON side built its own query. Splitting
into ActivitiesController gives the API its own home at
GET /:account/activities.json without dragging in the timeline
before_actions.

Also preloads comment creator in Event.preloaded to avoid an N+1 when
rendering comment eventables in the JSON feed.
This commit is contained in:
Rob Zolkos
2026-04-08 08:39:36 -04:00
committed by GitHub
parent 2a4adf3a2b
commit 6a71856b3d
10 changed files with 561 additions and 1 deletions
@@ -0,0 +1,272 @@
require "test_helper"
class ActivitiesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index returns flat array of events with required envelope fields" do
get activities_path, as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
assert body.any?
event = body.first
assert_includes event.keys, "id"
assert_includes event.keys, "action"
assert_includes event.keys, "created_at"
assert_includes event.keys, "description"
assert_includes event.keys, "particulars"
assert_includes event.keys, "url"
assert_includes event.keys, "eventable_type"
assert_includes event.keys, "eventable"
assert_includes event.keys, "board"
assert_includes event.keys, "creator"
end
test "index returns events in reverse-chronological order" do
get activities_path, as: :json
assert_response :success
times = @response.parsed_body.map { |e| Time.parse(e["created_at"]) }
assert_equal times, times.sort.reverse
end
test "index includes card eventable" do
event = events(:logo_published)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Card", item["eventable_type"]
assert_equal cards(:logo).title, item["eventable"]["title"]
assert_equal "card_published", item["action"]
assert item["url"].end_with?("/cards/#{cards(:logo).number}")
end
test "index includes comment eventable" do
event = events(:layout_commented)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Comment", item["eventable_type"]
assert_equal card_url(event.eventable.card, anchor: ActionView::RecordIdentifier.dom_id(event.eventable)), item["url"]
assert item["eventable"]["body"].present?
end
test "index includes board and creator at top level" do
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.first
assert item["board"]["id"].present?
assert item["board"]["name"].present?
assert item["creator"]["id"].present?
assert item["creator"]["name"].present?
end
test "index normalizes particulars for card_assigned" do
event = events(:logo_assignment_jz)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "card_assigned", item["action"]
assert_equal [ users(:jz).id ], item["particulars"]["assignee_ids"]
end
test "index normalizes particulars for card_unassigned" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_unassigned",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { assignee_ids: [ users(:jz).id ] }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal [ users(:jz).id ], item["particulars"]["assignee_ids"]
end
test "index returns empty particulars for actions with no payload" do
event = events(:logo_published)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal({}, item["particulars"])
end
test "index normalizes particulars for card_board_changed" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_board_changed",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { old_board: "Backlog", new_board: "Mobile" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Backlog", item["particulars"]["old_board"]
assert_equal "Mobile", item["particulars"]["new_board"]
end
test "index normalizes particulars for card_title_changed" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_title_changed",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { old_title: "Old title", new_title: "New title" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Old title", item["particulars"]["old_title"]
assert_equal "New title", item["particulars"]["new_title"]
end
test "index normalizes particulars for card_triaged" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_triaged",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { column: "In Progress" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "In Progress", item["particulars"]["column"]
end
test "index filters by creator_ids" do
get activities_path(creator_ids: [ users(:kevin).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["creator"]["id"] == users(:kevin).id }
end
test "index filters by multiple creator_ids with OR semantics" do
get activities_path(creator_ids: [ users(:david).id, users(:kevin).id ]), as: :json
assert_response :success
body = @response.parsed_body
creator_ids = body.map { |e| e["creator"]["id"] }.uniq.sort
assert_includes creator_ids, users(:david).id
assert_includes creator_ids, users(:kevin).id
end
test "index filters by board_ids" do
get activities_path(board_ids: [ boards(:writebook).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["board"]["id"] == boards(:writebook).id }
end
test "index ANDs creator_ids and board_ids filters" do
get activities_path(creator_ids: [ users(:david).id ], board_ids: [ boards(:writebook).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["creator"]["id"] == users(:david).id }
assert body.all? { |e| e["board"]["id"] == boards(:writebook).id }
end
test "index ignores empty creator_ids and board_ids filters" do
get activities_path(creator_ids: [], board_ids: []), as: :json
assert_response :success
assert_predicate @response.parsed_body, :any?
end
test "index only returns events from accessible boards" do
get activities_path, as: :json
assert_response :success
accessible_board_ids = users(:kevin).boards.pluck(:id)
@response.parsed_body.each do |item|
assert_includes accessible_board_ids, item["board"]["id"]
end
end
test "index paginates and returns Link header when more results exist" do
board = boards(:writebook)
30.times do |i|
board.events.create!(
action: "card_published",
creator: users(:kevin),
eventable: cards(:logo),
account: accounts("37s")
)
end
get activities_path, as: :json
assert_response :success
link_header = @response.headers["Link"]
assert link_header.present?, "Expected a Link header for paginated results"
assert_match(/rel="next"/, link_header)
end
test "index follows pagination to retrieve all events" do
board = boards(:writebook)
30.times do
board.events.create!(
action: "card_published",
creator: users(:kevin),
eventable: cards(:logo),
account: accounts("37s")
)
end
all_ids = []
next_path = activities_path(format: :json)
while next_path
get next_path
assert_response :success
all_ids.concat(@response.parsed_body.map { |e| e["id"] })
next_path = next_page_from_link_header(@response.headers["Link"])
end
total = Event.where(board: users(:kevin).boards).where(action: ActivitiesController::ACTIONS).count
assert_equal total, all_ids.uniq.count
end
private
def next_page_from_link_header(link_header)
url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first
URI.parse(url).request_uri if url
end
end
+53
View File
@@ -0,0 +1,53 @@
require "test_helper"
class EventTest < ActiveSupport::TestCase
test "blank creator and board filters return the current relation unchanged" do
relation = Event.where(action: "card_published")
assert_equal relation.to_sql, relation.for_creators(nil).to_sql
assert_equal relation.to_sql, relation.for_creators([]).to_sql
assert_equal relation.to_sql, relation.for_boards(nil).to_sql
assert_equal relation.to_sql, relation.for_boards([]).to_sql
end
test "blank creator and board filters remain chainable" do
relation = Event.where(action: "card_published")
assert_nothing_raised do
relation.for_creators([]).for_boards([]).load
end
end
test "api_particulars returns empty strings for missing nested board change values" do
event = boards(:writebook).events.create!(
action: "card_board_changed",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
assert_equal({ "old_board" => "", "new_board" => "" }, event.api_particulars)
end
test "api_particulars returns empty strings for missing nested title and column values" do
title_event = boards(:writebook).events.create!(
action: "card_title_changed",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
triage_event = boards(:writebook).events.create!(
action: "card_triaged",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
assert_equal({ "old_title" => "", "new_title" => "" }, title_event.api_particulars)
assert_equal({ "column" => "" }, triage_event.api_particulars)
end
end