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:
@@ -0,0 +1,30 @@
|
|||||||
|
class ActivitiesController < ApplicationController
|
||||||
|
ACTIONS = %w[
|
||||||
|
card_assigned
|
||||||
|
card_auto_postponed
|
||||||
|
card_board_changed
|
||||||
|
card_closed
|
||||||
|
card_postponed
|
||||||
|
card_published
|
||||||
|
card_reopened
|
||||||
|
card_sent_back_to_triage
|
||||||
|
card_title_changed
|
||||||
|
card_triaged
|
||||||
|
card_unassigned
|
||||||
|
comment_created
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def index
|
||||||
|
set_page_and_extract_portion_from(activities)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def activities
|
||||||
|
Current.user.accessible_events
|
||||||
|
.preloaded
|
||||||
|
.where(action: ACTIONS)
|
||||||
|
.for_creators(params[:creator_ids])
|
||||||
|
.for_boards(params[:board_ids])
|
||||||
|
.reverse_chronologically
|
||||||
|
end
|
||||||
|
end
|
||||||
+4
-1
@@ -9,10 +9,13 @@ class Event < ApplicationRecord
|
|||||||
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
|
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
|
||||||
|
|
||||||
scope :chronologically, -> { order created_at: :asc, id: :desc }
|
scope :chronologically, -> { order created_at: :asc, id: :desc }
|
||||||
|
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
|
||||||
|
scope :for_creators, ->(ids) { where(creator_id: ids) if ids.present? }
|
||||||
|
scope :for_boards, ->(ids) { where(board_id: ids) if ids.present? }
|
||||||
scope :preloaded, -> {
|
scope :preloaded, -> {
|
||||||
includes(:creator, :board, {
|
includes(:creator, :board, {
|
||||||
eventable: [
|
eventable: [
|
||||||
:goldness, :closure, :image_attachment,
|
:creator, :goldness, :closure, :image_attachment,
|
||||||
{ rich_text_body: :embeds_attachments },
|
{ rich_text_body: :embeds_attachments },
|
||||||
{ rich_text_description: :embeds_attachments },
|
{ rich_text_description: :embeds_attachments },
|
||||||
{ card: [ :goldness, :closure, :image_attachment ] }
|
{ card: [ :goldness, :closure, :image_attachment ] }
|
||||||
|
|||||||
@@ -8,4 +8,21 @@ module Event::Particulars
|
|||||||
def assignees
|
def assignees
|
||||||
@assignees ||= User.where id: assignee_ids
|
@assignees ||= User.where id: assignee_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def api_particulars
|
||||||
|
nested = particulars.dig("particulars") || {}
|
||||||
|
|
||||||
|
case action.to_s
|
||||||
|
when "card_assigned", "card_unassigned"
|
||||||
|
{ "assignee_ids" => Array(assignee_ids) }
|
||||||
|
when "card_board_changed"
|
||||||
|
{ "old_board" => nested["old_board"].to_s, "new_board" => nested["new_board"].to_s }
|
||||||
|
when "card_title_changed"
|
||||||
|
{ "old_title" => nested["old_title"].to_s, "new_title" => nested["new_title"].to_s }
|
||||||
|
when "card_triaged"
|
||||||
|
{ "column" => nested["column"].to_s }
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
json.(event, :id, :action)
|
||||||
|
json.created_at event.created_at.utc
|
||||||
|
json.description event.description_for(Current.user).to_plain_text
|
||||||
|
json.particulars event.api_particulars
|
||||||
|
json.url(
|
||||||
|
case event.eventable
|
||||||
|
when Comment then card_url(event.eventable.card, anchor: dom_id(event.eventable))
|
||||||
|
else polymorphic_url(event.eventable)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
json.eventable_type event.eventable_type
|
||||||
|
|
||||||
|
json.eventable do
|
||||||
|
case event.eventable
|
||||||
|
when Card then json.partial! "cards/card", card: event.eventable
|
||||||
|
when Comment then json.partial! "cards/comments/comment", comment: event.eventable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.board event.board, partial: "boards/board", as: :board
|
||||||
|
json.creator event.creator, partial: "users/user", as: :user
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.array! @page.records, partial: "activities/activity", as: :event
|
||||||
@@ -133,6 +133,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :activities, only: :index
|
||||||
resources :events, only: :index
|
resources :events, only: :index
|
||||||
namespace :events do
|
namespace :events do
|
||||||
resources :days
|
resources :days
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ a bot to perform various actions for you.
|
|||||||
- [Reactions](sections/reactions.md)
|
- [Reactions](sections/reactions.md)
|
||||||
- [Tags](sections/tags.md)
|
- [Tags](sections/tags.md)
|
||||||
- [Users](sections/users.md)
|
- [Users](sections/users.md)
|
||||||
|
- [Activities](sections/activities.md)
|
||||||
- [Notifications](sections/notifications.md)
|
- [Notifications](sections/notifications.md)
|
||||||
- [Rich Text](sections/rich_text.md)
|
- [Rich Text](sections/rich_text.md)
|
||||||
- [Exports](sections/exports.md)
|
- [Exports](sections/exports.md)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Activities
|
||||||
|
|
||||||
|
Activities are the activity stream for an account — a record of significant actions like cards being published, assigned, closed, and commented on.
|
||||||
|
|
||||||
|
## `GET /:account_slug/activities`
|
||||||
|
|
||||||
|
Returns a paginated flat list of activities the current user can access, sorted newest first.
|
||||||
|
|
||||||
|
__Query Parameters:__
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `creator_ids[]` | Filter to activities created by specific user ID(s). Multiple values are ORed. |
|
||||||
|
| `board_ids[]` | Filter to activities on specific board ID(s). Multiple values are ORed. |
|
||||||
|
|
||||||
|
Different filter params are ANDed together: `creator_ids[]=A&board_ids[]=X` means activities created by A on board X.
|
||||||
|
|
||||||
|
To fetch activity for a specific user, use `creator_ids[]=USER_ID`.
|
||||||
|
|
||||||
|
__Supported actions:__
|
||||||
|
|
||||||
|
| `action` | `eventable_type` | `particulars` shape |
|
||||||
|
|----------|-----------------|---------------------|
|
||||||
|
| `card_assigned` | `Card` | `{ "assignee_ids": [USER_ID, ...] }` |
|
||||||
|
| `card_auto_postponed` | `Card` | `{}` |
|
||||||
|
| `card_board_changed` | `Card` | `{ "old_board": STRING, "new_board": STRING }` |
|
||||||
|
| `card_closed` | `Card` | `{}` |
|
||||||
|
| `card_postponed` | `Card` | `{}` |
|
||||||
|
| `card_published` | `Card` | `{}` |
|
||||||
|
| `card_reopened` | `Card` | `{}` |
|
||||||
|
| `card_sent_back_to_triage` | `Card` | `{}` |
|
||||||
|
| `card_title_changed` | `Card` | `{ "old_title": STRING, "new_title": STRING }` |
|
||||||
|
| `card_triaged` | `Card` | `{ "column": STRING }` |
|
||||||
|
| `card_unassigned` | `Card` | `{ "assignee_ids": [USER_ID, ...] }` |
|
||||||
|
| `comment_created` | `Comment` | `{}` |
|
||||||
|
|
||||||
|
`particulars` is always an object. It contains action-specific metadata in a normalized format intended for API clients. It does not necessarily mirror the internal event JSON stored by Fizzy. Unknown keys may appear in the future and should be ignored. For `card_assigned` and `card_unassigned`, `assignee_ids` is currently a single-element array in practice.
|
||||||
|
|
||||||
|
__`particulars` examples:__
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "action": "card_assigned", "particulars": { "assignee_ids": ["03f5user123"] } }
|
||||||
|
{ "action": "card_unassigned", "particulars": { "assignee_ids": ["03f5user123"] } }
|
||||||
|
{ "action": "card_board_changed", "particulars": { "old_board": "Backlog", "new_board": "Mobile" } }
|
||||||
|
{ "action": "card_title_changed", "particulars": { "old_title": "Fix login", "new_title": "Fix mobile login" } }
|
||||||
|
{ "action": "card_triaged", "particulars": { "column": "In Progress" } }
|
||||||
|
{ "action": "card_closed", "particulars": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
The practical `eventable_type` values are `Card` and `Comment`. Clients should handle unknown future values conservatively.
|
||||||
|
|
||||||
|
Activities whose underlying `Card` or `Comment` has been deleted or is inaccessible to the current user are omitted from the feed. The endpoint never returns `eventable: null`.
|
||||||
|
|
||||||
|
The top-level `board` field reflects the activity's current board association. If a card moves boards, all its activities move with it for the purposes of this feed and `board_ids[]` filtering.
|
||||||
|
|
||||||
|
This endpoint is a paginated activity feed, not an immutable audit-log. `description`, `eventable`, `board`, and `creator` may reflect current resource state. Re-fetch recent pages to get fresh activity data.
|
||||||
|
|
||||||
|
__Response:__
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "03faevt004",
|
||||||
|
"action": "card_closed",
|
||||||
|
"created_at": "2026-03-25T15:11:04.000Z",
|
||||||
|
"description": "David Heinemeier Hansson moved \"Fix mobile login\" to \"Done\"",
|
||||||
|
"particulars": {},
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/cards/42",
|
||||||
|
"eventable_type": "Card",
|
||||||
|
"eventable": {
|
||||||
|
"id": "03f6card042",
|
||||||
|
"number": 42,
|
||||||
|
"title": "Fix mobile login",
|
||||||
|
"status": "closed",
|
||||||
|
"description": "Users cannot complete login on iOS.",
|
||||||
|
"description_html": "<div>Users cannot complete login on iOS.</div>",
|
||||||
|
"image_url": null,
|
||||||
|
"has_attachments": false,
|
||||||
|
"tags": ["ios", "auth"],
|
||||||
|
"closed": true,
|
||||||
|
"postponed": false,
|
||||||
|
"golden": false,
|
||||||
|
"last_active_at": "2026-03-25T15:11:04.000Z",
|
||||||
|
"created_at": "2026-03-25T09:00:00.000Z",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/cards/42"
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"id": "03f6abc123",
|
||||||
|
"name": "Mobile",
|
||||||
|
"all_access": true,
|
||||||
|
"created_at": "2026-03-01T10:00:00.000Z",
|
||||||
|
"auto_postpone_period_in_days": 14,
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/boards/03f6abc123"
|
||||||
|
},
|
||||||
|
"creator": {
|
||||||
|
"id": "03f5user123",
|
||||||
|
"name": "David Heinemeier Hansson",
|
||||||
|
"role": "owner",
|
||||||
|
"active": true,
|
||||||
|
"email_address": "david@example.com",
|
||||||
|
"created_at": "2026-03-01T09:00:00.000Z",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
|
||||||
|
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "03faevt003",
|
||||||
|
"action": "comment_created",
|
||||||
|
"created_at": "2026-03-25T14:17:22.000Z",
|
||||||
|
"description": "David Heinemeier Hansson commented on \"Fix mobile login\"",
|
||||||
|
"particulars": {},
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/cards/42#comment_03facomment9",
|
||||||
|
"eventable_type": "Comment",
|
||||||
|
"eventable": {
|
||||||
|
"id": "03facomment9",
|
||||||
|
"created_at": "2026-03-25T14:17:22.000Z",
|
||||||
|
"updated_at": "2026-03-25T14:17:22.000Z",
|
||||||
|
"body": {
|
||||||
|
"plain_text": "I found the regression in the callback flow.",
|
||||||
|
"html": "<div>I found the regression in the callback flow.</div>"
|
||||||
|
},
|
||||||
|
"creator": {
|
||||||
|
"id": "03f5user123",
|
||||||
|
"name": "David Heinemeier Hansson",
|
||||||
|
"role": "owner",
|
||||||
|
"active": true,
|
||||||
|
"email_address": "david@example.com",
|
||||||
|
"created_at": "2026-03-01T09:00:00.000Z",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
|
||||||
|
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"id": "03f6card042",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/cards/42"
|
||||||
|
},
|
||||||
|
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/42/comments/03facomment9/reactions",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/cards/42/comments/03facomment9"
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"id": "03f6abc123",
|
||||||
|
"name": "Mobile",
|
||||||
|
"all_access": true,
|
||||||
|
"created_at": "2026-03-01T10:00:00.000Z",
|
||||||
|
"auto_postpone_period_in_days": 14,
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/boards/03f6abc123"
|
||||||
|
},
|
||||||
|
"creator": {
|
||||||
|
"id": "03f5user123",
|
||||||
|
"name": "David Heinemeier Hansson",
|
||||||
|
"role": "owner",
|
||||||
|
"active": true,
|
||||||
|
"email_address": "david@example.com",
|
||||||
|
"created_at": "2026-03-01T09:00:00.000Z",
|
||||||
|
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
|
||||||
|
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
All `url` fields are opaque absolute URLs for the current Fizzy instance. Clients should not construct them.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user