723c8180b4
* Add JSON response format to void-response controller actions
Add respond_to blocks with JSON format to 14 controllers that
previously only rendered HTML or Turbo Stream responses. JSON
callers get head :no_content (or :created/:ok for push
subscriptions, distinguishing new vs existing).
Covers: board involvements, board/account entropies, column
reordering, card readings, card publishing, user roles, user
avatars, account settings, notification settings, join codes
(with 422 error branch), and push subscriptions.
* Add steps index endpoint with JSON view
Add index action to Cards::StepsController so SDK/CLI callers
can list all steps for a card. Reuses the existing _step.json
partial.
* Add JSON views for paginated card list endpoints
Add show.json.jbuilder for stream, not-now, and closed column
endpoints. No controller changes needed — set_page_and_extract_
portion_from and fresh_when already work format-agnostically.
* Add JSON search endpoint with distinct-card pagination
JSON search paginates distinct Card records (via the existing
Card.mentioning scope with .distinct) rather than Search::Record
objects. This ensures page boundaries, Link headers, and counts
reflect unique cards — no short pages from post-pagination dedup.
ID-match searches return a uniform single-element Card[] array
through the same pagination path.
* Add JSON views for settings and configuration endpoints
Add show.json.jbuilder for account settings (name), join codes
(code, usage_count, usage_limit, url, active), and notification
settings (bundle_email_frequency). Tests for show and update
were added in the void-response commit.
* Add JSON response format to data exports
Create returns 201 with export id, status, and created_at so
callers can poll. Show returns any-status exports for JSON (not
just completed) with a download_url when ready, or 404 for
missing/other-user exports.
* Extend access token JSON API
Add index.json and _access_token.json partial for listing tokens.
Add JSON format to destroy. Include id and created_at in the
create response alongside the existing token/description/permission
fields.
* Fix ambiguous precedence warning in export JSON view
* Assert X-Total-Count pagination header in streams JSON test
* Address review feedback
- Guard steps index against HTML requests (redirect to card)
- Normalize created_at to UTC in access token and export JSON
- Add deterministic ordering (.latest) to search JSON pagination
* Return 406 for HTML requests to steps index instead of redirect
* Add wrap_parameters for flat SDK JSON payload compatibility
Five controllers infer the wrong wrapper key from their class
name, so flat JSON bodies like {"role":"admin"} fail with 400.
Add explicit wrap_parameters declarations:
- Users::RolesController → :user
- Notifications::SettingsController → :user_settings
- Account::JoinCodesController → :account_join_code
- Account::SettingsController → :account
- Boards::EntropiesController → :board
Add integration tests that send flat (unwrapped) JSON payloads
for all seven param-bearing endpoints to prove SDK compatibility.
* Address PR review feedback
- Use 201 Created (not 204) for all create actions: publishes, readings,
left/right positions, push subscriptions
- Simplify push subscription to always return :created (no 200/201 variance)
- Remove superfluous respond_to block from steps#index
- Merge exports#show into single respond_to block
- Move export download_url conditional out of inline args
- Ensure join code active is explicitly boolean
- Remove extra parens from search controller assignment
- Add blank lines between format blocks for readability
* Return JSON bodies on create actions
Create actions for boards, cards, columns, comments, and steps now
render the resource as JSON (via their show views) instead of returning
empty 201 responses with only a Location header. SDKs expect a JSON
body they can deserialize into the created resource.
* Return JSON bodies on reaction create actions
Card and comment reaction creates now render the reaction as JSON
instead of returning empty 201 responses, consistent with the other
resource-creating endpoints.
* Allow access tokens API without account scope
Access tokens are identity-level (not account-scoped), so the
controller needs to work with bearer token auth and no account slug
in the URL. Skip require_account so the bearer token auth path
can proceed.
Also fix involvement test to send params in JSON body (not query
string) to match real SDK usage.
320 lines
9.0 KiB
Ruby
320 lines
9.0 KiB
Ruby
require "test_helper"
|
|
|
|
class BoardsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in_as :kevin
|
|
end
|
|
|
|
test "new" do
|
|
get new_board_path
|
|
assert_response :success
|
|
end
|
|
|
|
test "show" do
|
|
get board_path(boards(:writebook))
|
|
assert_response :success
|
|
end
|
|
|
|
test "invalidates page title cache when account updates" do
|
|
get board_path(boards(:writebook))
|
|
etag = response.headers["ETag"]
|
|
|
|
accounts("37s").update!(name: "Renamed Account")
|
|
|
|
get board_path(boards(:writebook)), headers: { "If-None-Match" => etag }
|
|
assert_response :success
|
|
end
|
|
|
|
test "create" do
|
|
assert_difference -> { Board.count }, +1 do
|
|
post boards_path, params: { board: { name: "Remodel Punch List" } }
|
|
end
|
|
|
|
board = Board.last
|
|
assert_redirected_to board_path(board)
|
|
assert_includes board.users, users(:kevin)
|
|
assert_equal "Remodel Punch List", board.name
|
|
end
|
|
|
|
test "edit" do
|
|
get edit_board_path(boards(:writebook))
|
|
assert_response :success
|
|
end
|
|
|
|
test "update" do
|
|
patch board_path(boards(:writebook)), params: {
|
|
board: {
|
|
name: "Writebook bugs",
|
|
all_access: false,
|
|
auto_postpone_period: 1.day
|
|
},
|
|
user_ids: users(:kevin, :jz).pluck(:id)
|
|
}
|
|
|
|
assert_redirected_to edit_board_path(boards(:writebook))
|
|
assert_equal "Writebook bugs", boards(:writebook).reload.name
|
|
assert_equal users(:kevin, :jz).sort, boards(:writebook).users.sort
|
|
assert_equal 1.day, entropies(:writebook_board).auto_postpone_period
|
|
assert_not boards(:writebook).all_access?
|
|
end
|
|
|
|
test "update redirects to root when user removes themselves from board" do
|
|
board = boards(:writebook)
|
|
|
|
patch board_path(board), params: {
|
|
board: { name: "Updated name", all_access: false },
|
|
user_ids: users(:david, :jz).pluck(:id)
|
|
}
|
|
|
|
assert_redirected_to root_path
|
|
assert_not board.reload.users.include?(users(:kevin))
|
|
end
|
|
|
|
test "update board with granular permissions, submitting no user ids" do
|
|
assert_not boards(:private).all_access?
|
|
|
|
boards(:private).users = [ users(:kevin) ]
|
|
boards(:private).save!
|
|
|
|
patch board_path(boards(:private)), params: {
|
|
board: { name: "Renamed" }
|
|
}
|
|
|
|
assert_redirected_to edit_board_path(boards(:private))
|
|
assert_equal "Renamed", boards(:private).reload.name
|
|
assert_equal [ users(:kevin) ], boards(:private).users
|
|
assert_not boards(:private).all_access?
|
|
end
|
|
|
|
test "update all access" do
|
|
board = Current.set(account: accounts("37s"), session: sessions(:kevin), user: users(:kevin)) do
|
|
Board.create! name: "New board", all_access: false
|
|
end
|
|
assert_equal [ users(:kevin) ], board.users
|
|
|
|
patch board_path(board), params: { board: { name: "Bugs", all_access: true } }
|
|
|
|
assert_redirected_to edit_board_path(board)
|
|
assert board.reload.all_access?
|
|
assert_equal accounts("37s").users.active.sort, board.users.sort
|
|
end
|
|
|
|
test "destroy" do
|
|
board = boards(:writebook)
|
|
delete board_path(board)
|
|
assert_redirected_to root_path
|
|
assert_raises(ActiveRecord::RecordNotFound) { board.reload }
|
|
end
|
|
|
|
test "non-admin cannot change all_access on board they don't own" do
|
|
logout_and_sign_in_as :jz
|
|
|
|
board = boards(:writebook)
|
|
original_all_access = board.all_access
|
|
|
|
patch board_path(board), params: { board: { all_access: !original_all_access } }
|
|
|
|
assert_response :forbidden
|
|
assert_equal original_all_access, board.reload.all_access
|
|
end
|
|
|
|
test "non-admin cannot change individual user accesses on board they don't own" do
|
|
logout_and_sign_in_as :jz
|
|
|
|
board = boards(:writebook)
|
|
original_users = board.users.sort
|
|
|
|
patch board_path(board), params: {
|
|
board: { name: board.name },
|
|
user_ids: [ users(:jz).id ]
|
|
}
|
|
|
|
assert_response :forbidden
|
|
assert_equal original_users, board.reload.users.sort
|
|
end
|
|
|
|
test "non-admin cannot change board name on board they don't own" do
|
|
logout_and_sign_in_as :jz
|
|
|
|
board = boards(:writebook)
|
|
original_name = board.name
|
|
|
|
patch board_path(board), params: {
|
|
board: { name: "Hacked Board Name" }
|
|
}
|
|
|
|
assert_response :forbidden
|
|
assert_equal original_name, board.reload.name
|
|
end
|
|
|
|
test "non-admin cannot destroy board they don't own" do
|
|
logout_and_sign_in_as :jz
|
|
|
|
board = boards(:writebook)
|
|
delete board_path(board)
|
|
|
|
assert_response :forbidden
|
|
end
|
|
|
|
test "disables select all/none buttons for non-privileged user" do
|
|
logout_and_sign_in_as :jz
|
|
assert_not users(:jz).can_administer_board?(boards(:writebook))
|
|
|
|
get edit_board_path(boards(:writebook))
|
|
|
|
assert_response :success
|
|
assert_select "button[disabled]", text: "Select all"
|
|
assert_select "button[disabled]", text: "Select none"
|
|
end
|
|
|
|
test "enables select all/none buttons for privileged user" do
|
|
assert users(:kevin).can_administer_board?(boards(:writebook))
|
|
|
|
get edit_board_path(boards(:writebook))
|
|
|
|
assert_response :success
|
|
assert_select "button:not([disabled])", text: "Select all"
|
|
assert_select "button:not([disabled])", text: "Select none"
|
|
end
|
|
|
|
test "access toggle disabled state is cached correctly" do
|
|
board = boards(:writebook)
|
|
david = users(:david)
|
|
|
|
with_actionview_partial_caching do
|
|
# privileged user
|
|
assert users(:kevin).can_administer_board?(board)
|
|
|
|
get edit_board_path(board)
|
|
|
|
assert_response :success
|
|
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])"
|
|
|
|
# unprivileged user
|
|
logout_and_sign_in_as :jz
|
|
assert_not users(:jz).can_administer_board?(board)
|
|
|
|
get edit_board_path(board)
|
|
|
|
assert_response :success
|
|
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]"
|
|
end
|
|
end
|
|
|
|
test "index as JSON" do
|
|
get boards_path, as: :json
|
|
assert_response :success
|
|
assert_equal users(:kevin).boards.count, @response.parsed_body.count
|
|
end
|
|
|
|
test "index as JSON paginates and preserves recently-accessed order" do
|
|
account = accounts("37s")
|
|
kevin = users(:kevin)
|
|
baseline_accessed_at = 3.days.ago.change(usec: 0)
|
|
|
|
kevin.accesses.order(:id).each_with_index do |access, index|
|
|
access.update!(accessed_at: baseline_accessed_at + index.seconds)
|
|
end
|
|
|
|
200.times do |index|
|
|
board = Board.create!(
|
|
name: "Recent board #{index}",
|
|
creator: kevin,
|
|
account: account,
|
|
all_access: false
|
|
)
|
|
board.access_for(kevin).update!(accessed_at: baseline_accessed_at + (index + 1).minutes)
|
|
end
|
|
|
|
expected_ids = kevin.boards.ordered_by_recently_accessed.pluck(:id)
|
|
actual_ids = []
|
|
next_page = boards_path(format: :json)
|
|
page_count = 0
|
|
|
|
while next_page
|
|
get next_page, as: :json
|
|
assert_response :success
|
|
|
|
page_count += 1
|
|
actual_ids.concat(@response.parsed_body.map { |board| board["id"] })
|
|
next_page = next_page_from_link_header(@response.headers["Link"])
|
|
end
|
|
|
|
assert_equal expected_ids, actual_ids
|
|
assert_operator page_count, :>, 1
|
|
end
|
|
|
|
test "show as JSON" do
|
|
get board_path(boards(:writebook)), as: :json
|
|
assert_response :success
|
|
assert_equal boards(:writebook).name, @response.parsed_body["name"]
|
|
end
|
|
|
|
test "show as JSON includes public_url when published" do
|
|
board = boards(:writebook)
|
|
board.publish
|
|
|
|
get board_path(board), as: :json
|
|
assert_response :success
|
|
assert_equal published_board_url(board), @response.parsed_body["public_url"]
|
|
end
|
|
|
|
test "show as JSON excludes public_url when not published" do
|
|
board = boards(:writebook)
|
|
assert_not board.published?
|
|
|
|
get board_path(board), as: :json
|
|
assert_response :success
|
|
assert_nil @response.parsed_body["public_url"]
|
|
end
|
|
|
|
test "create as JSON" do
|
|
assert_difference -> { Board.count }, +1 do
|
|
post boards_path, params: { board: { name: "My new board" } }, as: :json
|
|
end
|
|
|
|
assert_response :created
|
|
assert_equal board_path(Board.last, format: :json), @response.headers["Location"]
|
|
assert_equal "My new board", @response.parsed_body["name"]
|
|
end
|
|
|
|
test "update as JSON" do
|
|
board = boards(:writebook)
|
|
|
|
put board_path(board), params: { board: { name: "Updated Name" } }, as: :json
|
|
|
|
assert_response :no_content
|
|
assert_equal "Updated Name", board.reload.name
|
|
end
|
|
|
|
test "destroy as JSON" do
|
|
board = boards(:writebook)
|
|
|
|
assert_difference -> { Board.count }, -1 do
|
|
delete board_path(board), as: :json
|
|
end
|
|
|
|
assert_response :no_content
|
|
end
|
|
|
|
test "index avoids N+1 queries on creator and identity" do
|
|
assert_queries_match(/FROM [`"]users[`"].* IN \(/, count: 1) do
|
|
assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do
|
|
get boards_path, as: :json
|
|
assert_response :success
|
|
end
|
|
end
|
|
|
|
json = @response.parsed_body
|
|
first_board = json.first
|
|
assert first_board["creator"].present?
|
|
assert first_board["creator"]["email_address"].present?
|
|
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
|