Add JSON response format for full SDK/CLI coverage (#2675)

* 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.
This commit is contained in:
Jeremy Daer
2026-03-09 11:39:49 -07:00
committed by GitHub
parent a896b0a9e6
commit 723c8180b4
62 changed files with 580 additions and 31 deletions
@@ -3,7 +3,11 @@ class Account::EntropiesController < ApplicationController
def update def update
Current.account.entropy.update!(entropy_params) Current.account.entropy.update!(entropy_params)
redirect_to account_settings_path, notice: "Account updated"
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Account updated" }
format.json { head :no_content }
end
end end
private private
+13 -3
View File
@@ -6,11 +6,20 @@ class Account::ExportsController < ApplicationController
CURRENT_EXPORT_LIMIT = 10 CURRENT_EXPORT_LIMIT = 10
def show def show
respond_to do |format|
format.html
format.json { @export ? render(:show) : head(:not_found) }
end
end end
def create def create
Current.account.exports.create!(user: Current.user).build_later @export = Current.account.exports.create!(user: Current.user)
redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." @export.build_later
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." }
format.json { render :show, status: :created }
end
end end
private private
@@ -23,6 +32,7 @@ class Account::ExportsController < ApplicationController
end end
def set_export def set_export
@export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user) scope = request.format.json? ? Current.account.exports : Current.account.exports.completed
@export = scope.find_by(id: params[:id], user: Current.user)
end end
end end
@@ -1,4 +1,6 @@
class Account::JoinCodesController < ApplicationController class Account::JoinCodesController < ApplicationController
wrap_parameters :account_join_code
before_action :set_join_code before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ] before_action :ensure_admin, only: %i[ update destroy ]
@@ -10,15 +12,25 @@ class Account::JoinCodesController < ApplicationController
def update def update
if @join_code.update(join_code_params) if @join_code.update(join_code_params)
redirect_to account_join_code_path respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
else else
render :edit, status: :unprocessable_entity respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @join_code.errors, status: :unprocessable_entity }
end
end end
end end
def destroy def destroy
@join_code.reset @join_code.reset
redirect_to account_join_code_path
respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
end end
private private
@@ -1,4 +1,6 @@
class Account::SettingsController < ApplicationController class Account::SettingsController < ApplicationController
wrap_parameters :account
before_action :ensure_admin, only: :update before_action :ensure_admin, only: :update
before_action :set_account before_action :set_account
@@ -8,7 +10,11 @@ class Account::SettingsController < ApplicationController
def update def update
@account.update!(account_params) @account.update!(account_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end end
private private
+1 -1
View File
@@ -18,7 +18,7 @@ class Boards::ColumnsController < ApplicationController
respond_to do |format| respond_to do |format|
format.turbo_stream format.turbo_stream
format.json { head :created, location: board_column_path(@board, @column, format: :json) } format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) }
end end
end end
@@ -1,10 +1,17 @@
class Boards::EntropiesController < ApplicationController class Boards::EntropiesController < ApplicationController
wrap_parameters :board
include BoardScoped include BoardScoped
before_action :ensure_permission_to_admin_board before_action :ensure_permission_to_admin_board
def update def update
@board.update!(entropy_params) @board.update!(entropy_params)
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end end
private private
@@ -3,5 +3,11 @@ class Boards::InvolvementsController < ApplicationController
def update def update
@board.access_for(Current.user).update!(involvement: params[:involvement]) @board.access_for(Current.user).update!(involvement: params[:involvement])
respond_to do |format|
format.html
format.turbo_stream
format.json { head :no_content }
end
end end
end end
+1 -1
View File
@@ -26,7 +26,7 @@ class BoardsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html { redirect_to board_path(@board) } format.html { redirect_to board_path(@board) }
format.json { head :created, location: board_path(@board, format: :json) } format.json { render :show, status: :created, location: board_path(@board, format: :json) }
end end
end end
@@ -22,7 +22,7 @@ class Cards::Comments::ReactionsController < ApplicationController
respond_to do |format| respond_to do |format|
format.turbo_stream { render "reactions/create" } format.turbo_stream { render "reactions/create" }
format.json { head :created } format.json { render "reactions/show", status: :created }
end end
end end
+1 -1
View File
@@ -14,7 +14,7 @@ class Cards::CommentsController < ApplicationController
respond_to do |format| respond_to do |format|
format.turbo_stream format.turbo_stream
format.json { head :created, location: card_comment_path(@card, @comment, format: :json) } format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) }
end end
end end
+11 -5
View File
@@ -4,11 +4,17 @@ class Cards::PublishesController < ApplicationController
def create def create
@card.publish @card.publish
if add_another_param? respond_to do |format|
card = @board.cards.create!(status: :drafted) format.html do
redirect_to card_draft_path(card), notice: "Card added" if add_another_param?
else card = @board.cards.create!(status: :drafted)
redirect_to @card.board redirect_to card_draft_path(card), notice: "Card added"
else
redirect_to @card.board
end
end
format.json { head :created }
end end
end end
@@ -21,7 +21,7 @@ class Cards::ReactionsController < ApplicationController
respond_to do |format| respond_to do |format|
format.turbo_stream { render "reactions/create" } format.turbo_stream { render "reactions/create" }
format.json { head :created } format.json { render "reactions/show", status: :created }
end end
end end
@@ -4,11 +4,21 @@ class Cards::ReadingsController < ApplicationController
def create def create
@notification = @card.read_by(Current.user) @notification = @card.read_by(Current.user)
record_board_access record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end end
def destroy def destroy
@notification = @card.unread_by(Current.user) @notification = @card.unread_by(Current.user)
record_board_access record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end end
private private
+5 -1
View File
@@ -3,12 +3,16 @@ class Cards::StepsController < ApplicationController
before_action :set_step, only: %i[ show edit update destroy ] before_action :set_step, only: %i[ show edit update destroy ]
def index
fresh_when etag: @card.steps
end
def create def create
@step = @card.steps.create!(step_params) @step = @card.steps.create!(step_params)
respond_to do |format| respond_to do |format|
format.turbo_stream format.turbo_stream
format.json { head :created, location: card_step_path(@card, @step, format: :json) } format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) }
end end
end end
+2 -2
View File
@@ -18,8 +18,8 @@ class CardsController < ApplicationController
end end
format.json do format.json do
card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") @card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
head :created, location: card_path(card, format: :json) render :show, status: :created, location: card_path(@card, format: :json)
end end
end end
end end
@@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController
def create def create
@left_column = @column.left_column @left_column = @column.left_column
@column.move_left @column.move_left
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end end
end end
@@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController
def create def create
@right_column = @column.right_column @right_column = @column.right_column
@column.move_right @column.move_right
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end end
end end
@@ -1,4 +1,6 @@
class My::AccessTokensController < ApplicationController class My::AccessTokensController < ApplicationController
skip_before_action :require_account
def index def index
@access_tokens = my_access_tokens.order(created_at: :desc) @access_tokens = my_access_tokens.order(created_at: :desc)
end end
@@ -24,14 +26,19 @@ class My::AccessTokensController < ApplicationController
format.json do format.json do
render status: :created, json: \ render status: :created, json: \
{ token: access_token.token, description: access_token.description, permission: access_token.permission } { id: access_token.id, token: access_token.token, description: access_token.description,
permission: access_token.permission, created_at: access_token.created_at.utc }
end end
end end
end end
def destroy def destroy
my_access_tokens.find(params[:id]).destroy! my_access_tokens.find(params[:id]).destroy!
redirect_to my_access_tokens_path
respond_to do |format|
format.html { redirect_to my_access_tokens_path }
format.json { head :no_content }
end
end end
private private
@@ -1,4 +1,6 @@
class Notifications::SettingsController < ApplicationController class Notifications::SettingsController < ApplicationController
wrap_parameters :user_settings
before_action :set_settings before_action :set_settings
def show def show
@@ -7,7 +9,11 @@ class Notifications::SettingsController < ApplicationController
def update def update
@settings.update!(settings_params) @settings.update!(settings_params)
redirect_to notifications_settings_path, notice: "Settings updated"
respond_to do |format|
format.html { redirect_to notifications_settings_path, notice: "Settings updated" }
format.json { head :no_content }
end
end end
private private
+15 -3
View File
@@ -5,10 +5,22 @@ class SearchesController < ApplicationController
@query = params[:q].blank? ? nil : params[:q] @query = params[:q].blank? ? nil : params[:q]
if card = Current.user.accessible_cards.find_by_id(@query) if card = Current.user.accessible_cards.find_by_id(@query)
@card = card respond_to do |format|
format.html { @card = card }
format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) }
end
else else
set_page_and_extract_portion_from Current.user.search(@query) respond_to do |format|
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) format.html do
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
end
format.json do
set_page_and_extract_portion_from \
Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded
end
end
end end
end end
end end
+5 -1
View File
@@ -16,7 +16,11 @@ class Users::AvatarsController < ApplicationController
def destroy def destroy
@user.avatar.destroy @user.avatar.destroy
redirect_to @user
respond_to do |format|
format.html { redirect_to @user }
format.json { head :no_content }
end
end end
private private
@@ -5,12 +5,21 @@ class Users::PushSubscriptionsController < ApplicationController
end end
def create def create
@push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
respond_to do |format|
format.html { head :no_content }
format.json { head :created }
end
end end
def destroy def destroy
@push_subscriptions.destroy_by(id: params[:id]) @push_subscriptions.destroy_by(id: params[:id])
redirect_to user_push_subscriptions_url
respond_to do |format|
format.html { redirect_to user_push_subscriptions_url }
format.json { head :no_content }
end
end end
private private
+7 -1
View File
@@ -1,10 +1,16 @@
class Users::RolesController < ApplicationController class Users::RolesController < ApplicationController
wrap_parameters :user
before_action :set_user before_action :set_user
before_action :ensure_permission_to_administer_user before_action :ensure_permission_to_administer_user
def update def update
@user.update!(role_params) @user.update!(role_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end end
private private
@@ -0,0 +1,6 @@
json.(@export, :id, :status)
json.created_at @export.created_at.utc
if @export.completed? && @export.file.attached?
json.download_url rails_blob_url(@export.file, disposition: "attachment")
end
@@ -0,0 +1,3 @@
json.(@join_code, :code, :usage_count, :usage_limit)
json.url join_url(code: @join_code.code, script_name: Current.account.slug)
json.active !!@join_code.active?
@@ -0,0 +1 @@
json.(Current.account, :name)
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -0,0 +1 @@
json.array! @card.steps, partial: "cards/steps/step", as: :step
@@ -0,0 +1,2 @@
json.(access_token, :id, :description, :permission)
json.created_at access_token.created_at.utc
@@ -0,0 +1 @@
json.array! @access_tokens, partial: "my/access_tokens/access_token", as: :access_token
@@ -0,0 +1 @@
json.bundle_email_frequency Current.user.settings.bundle_email_frequency
+1
View File
@@ -0,0 +1 @@
json.partial! "reactions/reaction", reaction: @reaction
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -13,6 +13,13 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path assert_redirected_to account_settings_path
end end
test "update as JSON" do
put account_entropy_path, params: { entropy: { auto_postpone_period: 2.days } }, as: :json
assert_response :no_content
assert_equal 2.days, entropies("37s_account").reload.auto_postpone_period
end
test "update requires admin" do test "update requires admin" do
logout_and_sign_in_as :david logout_and_sign_in_as :david
@@ -76,6 +76,49 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
assert_select "h2", "Download Expired" assert_select "h2", "Download Expired"
end end
test "create as JSON" do
assert_difference -> { Account::Export.count }, 1 do
assert_enqueued_with(job: DataExportJob) do
post account_exports_path, as: :json
end
end
assert_response :created
body = @response.parsed_body
assert body["id"].present?
assert_equal "pending", body["status"]
assert_nil body["download_url"]
end
test "show as JSON with completed export" do
export = Account::Export.create!(account: Current.account, user: users(:jason))
export.build
get account_export_path(export), as: :json
assert_response :success
body = @response.parsed_body
assert_equal export.id, body["id"]
assert_equal "completed", body["status"]
assert body["download_url"].present?
end
test "show as JSON with pending export" do
export = Account::Export.create!(account: Current.account, user: users(:jason))
get account_export_path(export), as: :json
assert_response :success
body = @response.parsed_body
assert_equal "pending", body["status"]
assert_nil body["download_url"]
end
test "show as JSON with missing export" do
get account_export_path("nonexistent"), as: :json
assert_response :not_found
end
test "create is forbidden for non-admin members" do test "create is forbidden for non-admin members" do
logout_and_sign_in_as :david logout_and_sign_in_as :david
@@ -24,6 +24,41 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_join_code_path assert_redirected_to account_join_code_path
end end
test "show as JSON" do
get account_join_code_path, as: :json
assert_response :success
body = @response.parsed_body
assert body["code"].present?
assert body.key?("usage_count")
assert body.key?("usage_limit")
assert body.key?("url")
assert body.key?("active")
end
test "update as JSON" do
put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }, as: :json
assert_response :no_content
assert_equal 5, Current.account.join_code.reload.usage_limit
end
test "update as JSON with invalid data" do
huge_number = "99999999999999999999999999999999999"
put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } }, as: :json
assert_response :unprocessable_entity
end
test "destroy as JSON" do
assert_changes -> { Current.account.join_code.reload.code } do
delete account_join_code_path, as: :json
end
assert_response :no_content
end
test "update requires admin" do test "update requires admin" do
logout_and_sign_in_as :david logout_and_sign_in_as :david
@@ -16,6 +16,20 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path assert_redirected_to account_settings_path
end end
test "show as JSON" do
get account_settings_path, as: :json
assert_response :success
assert_equal Current.account.name, @response.parsed_body["name"]
end
test "update as JSON" do
put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json
assert_response :no_content
assert_equal "New Account Name", Current.account.reload.name
end
test "update requires admin" do test "update requires admin" do
logout_and_sign_in_as :david logout_and_sign_in_as :david
@@ -0,0 +1,71 @@
require "test_helper"
class FlatJsonParamsTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "update user role with flat JSON" do
put user_role_path(users(:david)), params: { role: "admin" }, as: :json
assert_response :no_content
assert users(:david).reload.admin?
end
test "update notification settings with flat JSON" do
logout_and_sign_in_as :david
assert_changes -> { users(:david).reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
put notifications_settings_path, params: { bundle_email_frequency: "every_few_hours" }, as: :json
end
assert_response :no_content
end
test "update join code with flat JSON" do
put account_join_code_path, params: { usage_limit: 5 }, as: :json
assert_response :no_content
assert_equal 5, Current.account.join_code.reload.usage_limit
end
test "update account settings with flat JSON" do
put account_settings_path, params: { name: "New Name" }, as: :json
assert_response :no_content
assert_equal "New Name", Current.account.reload.name
end
test "update board entropy with flat JSON" do
board = boards(:writebook)
put board_entropy_path(board), params: { auto_postpone_period: 99.days }, as: :json
assert_response :no_content
assert_equal 99.days, board.entropy.reload.auto_postpone_period
end
test "update account entropy with flat JSON" do
put account_entropy_path, params: { auto_postpone_period: 2.days }, as: :json
assert_response :no_content
assert_equal 2.days, Current.account.entropy.reload.auto_postpone_period
end
test "create push subscription with flat JSON" do
stub_dns_resolution("142.250.185.206")
post user_push_subscriptions_path(users(:kevin)),
params: { endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "key1", auth_key: "key2" },
as: :json
assert_response :created
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
dns_mock.stubs(:each_address).multiple_yields(*ips)
Resolv::DNS.stubs(:open).yields(dns_mock)
end
end
@@ -9,4 +9,11 @@ class Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest
get board_columns_closed_path(boards(:writebook)) get board_columns_closed_path(boards(:writebook))
assert_response :success assert_response :success
end end
test "show as JSON" do
get board_columns_closed_path(boards(:writebook)), as: :json
assert_response :success
assert_kind_of Array, @response.parsed_body
end
end end
@@ -9,4 +9,11 @@ class Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest
get board_columns_not_now_path(boards(:writebook)) get board_columns_not_now_path(boards(:writebook))
assert_response :success assert_response :success
end end
test "show as JSON" do
get board_columns_not_now_path(boards(:writebook)), as: :json
assert_response :success
assert_kind_of Array, @response.parsed_body
end
end end
@@ -9,4 +9,12 @@ class Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest
get board_columns_stream_path(boards(:writebook)) get board_columns_stream_path(boards(:writebook))
assert_response :success assert_response :success
end end
test "show as JSON" do
get board_columns_stream_path(boards(:writebook)), as: :json
assert_response :success
assert_kind_of Array, @response.parsed_body
assert response.headers["X-Total-Count"].present?, "Expected X-Total-Count header"
end
end end
@@ -75,6 +75,7 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest
assert_response :created assert_response :created
assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"] assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"]
assert_equal "New Column", @response.parsed_body["name"]
end end
test "update as JSON" do test "update as JSON" do
@@ -16,6 +16,13 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
end end
end end
test "update as JSON" do
put board_entropy_path(@board), params: { board: { auto_postpone_period: 99.days } }, as: :json
assert_response :no_content
assert_equal 99.days, @board.entropy.reload.auto_postpone_period
end
test "update requires board admin permission" do test "update requires board admin permission" do
logout_and_sign_in_as :jz logout_and_sign_in_as :jz
@@ -15,4 +15,15 @@ class Boards::InvolvementsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "update as JSON" do
board = boards(:writebook)
board.access_for(users(:kevin)).access_only!
assert_changes -> { board.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do
put board_involvement_path(board), params: { involvement: "watching" }, as: :json
end
assert_response :no_content
end
end end
@@ -275,6 +275,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :created assert_response :created
assert_equal board_path(Board.last, format: :json), @response.headers["Location"] assert_equal board_path(Board.last, format: :json), @response.headers["Location"]
assert_equal "My new board", @response.parsed_body["name"]
end end
test "update as JSON" do test "update as JSON" do
@@ -49,6 +49,7 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest
end end
assert_response :created assert_response :created
assert_equal "👍", @response.parsed_body["content"]
end end
test "destroy as JSON" do test "destroy as JSON" do
@@ -56,6 +56,7 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
assert_response :created assert_response :created
assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"] assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"]
assert_equal Comment.last.id, @response.parsed_body["id"]
end end
test "create as JSON with custom created_at" do test "create as JSON with custom created_at" do
@@ -16,6 +16,17 @@ class Cards::PublishesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card.board assert_redirected_to card.board
end end
test "create as JSON" do
card = cards(:logo)
card.drafted!
assert_changes -> { card.reload.published? }, from: false, to: true do
post card_publish_path(card), as: :json
end
assert_response :created
end
test "create and add another" do test "create and add another" do
card = cards(:logo) card = cards(:logo)
card.drafted! card.drafted!
@@ -53,6 +53,7 @@ class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
end end
assert_response :created assert_response :created
assert_equal "👍", @response.parsed_body["content"]
end end
test "destroy as JSON" do test "destroy as JSON" do
@@ -39,6 +39,18 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "create as JSON" do
post card_reading_url(cards(:logo)), as: :json
assert_response :created
end
test "destroy as JSON" do
notifications(:logo_assignment_kevin).read
delete card_reading_url(cards(:logo)), as: :json
assert_response :no_content
end
test "unread notification on destroy" do test "unread notification on destroy" do
notifications(:logo_assignment_kevin).read notifications(:logo_assignment_kevin).read
@@ -53,6 +53,19 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
end end
end end
test "index as JSON" do
card = cards(:logo)
card.steps.create!(content: "Step one")
card.steps.create!(content: "Step two", completed: true)
get card_steps_path(card), as: :json
assert_response :success
body = @response.parsed_body
assert_equal 2, body.size
assert_equal "Step one", body.first["content"]
end
test "create as JSON" do test "create as JSON" do
card = cards(:logo) card = cards(:logo)
@@ -62,6 +75,7 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
assert_response :created assert_response :created
assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"] assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"]
assert_equal "New step", @response.parsed_body["content"]
end end
test "show as JSON" do test "show as JSON" do
@@ -192,6 +192,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
card = Card.last card = Card.last
assert_equal card_path(card, format: :json), @response.headers["Location"] assert_equal card_path(card, format: :json), @response.headers["Location"]
assert_equal "My new card", @response.parsed_body["title"]
assert_equal "My new card", card.title assert_equal "My new card", card.title
assert_equal "Big if true", card.description.to_plain_text assert_equal "Big if true", card.description.to_plain_text
@@ -21,6 +21,22 @@ class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest
assert_equal original_position_a, column_b.reload.position assert_equal original_position_a, column_b.reload.position
end end
test "move column left as JSON" do
board = boards(:writebook)
columns = board.columns.sorted.to_a
column_a = columns[0]
column_b = columns[1]
original_position_a = column_a.position
original_position_b = column_b.position
post column_left_position_path(column_b), as: :json
assert_response :created
assert_equal original_position_b, column_a.reload.position
assert_equal original_position_a, column_b.reload.position
end
test "move left refreshes adjacent columns" do test "move left refreshes adjacent columns" do
column = columns(:writebook_in_progress) column = columns(:writebook_in_progress)
@@ -21,6 +21,22 @@ class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest
assert_equal original_position_a, column_b.reload.position assert_equal original_position_a, column_b.reload.position
end end
test "move column right as JSON" do
board = boards(:writebook)
columns = board.columns.sorted.to_a
column_a = columns[0]
column_b = columns[1]
original_position_a = column_a.position
original_position_b = column_b.position
post column_right_position_path(column_a), as: :json
assert_response :created
assert_equal original_position_b, column_a.reload.position
assert_equal original_position_a, column_b.reload.position
end
test "move right refreshes adjacent columns" do test "move right refreshes adjacent columns" do
column = columns(:writebook_in_progress) column = columns(:writebook_in_progress)
@@ -25,9 +25,11 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
end end
assert_response :created assert_response :created
body = @response.parsed_body body = @response.parsed_body
assert body["id"].present?
assert body["token"].present? assert body["token"].present?
assert_equal "Fizzy CLI", body["description"] assert_equal "Fizzy CLI", body["description"]
assert_equal "write", body["permission"] assert_equal "write", body["permission"]
assert body["created_at"].present?
end end
test "create new token via JSON with bearer token" do test "create new token via JSON with bearer token" do
@@ -54,6 +56,36 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
assert_response :unauthorized assert_response :unauthorized
end end
test "index as JSON" do
get my_access_tokens_path, as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
end
test "index as JSON with bearer token and no account scope" do
sign_out
bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" }
untenanted do
get my_access_tokens_path, as: :json, env: bearer_token
end
assert_response :success
assert_kind_of Array, @response.parsed_body
end
test "destroy as JSON" do
token = identities(:kevin).access_tokens.create!(description: "To delete", permission: "read")
assert_difference -> { identities(:kevin).access_tokens.count }, -1 do
delete my_access_token_path(token), as: :json
end
assert_response :no_content
end
test "accessing new token after reveal window redirects to index" do test "accessing new token after reveal window redirects to index" do
assert_changes -> { identities(:kevin).access_tokens.count }, +1 do assert_changes -> { identities(:kevin).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } }
@@ -13,6 +13,21 @@ class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "show as JSON" do
get notifications_settings_path, as: :json
assert_response :success
assert_equal @user.settings.bundle_email_frequency, @response.parsed_body["bundle_email_frequency"]
end
test "update as JSON" do
assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }, as: :json
end
assert_response :no_content
end
test "update email frequency" do test "update email frequency" do
assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } } put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }
@@ -46,6 +46,36 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select ".search__blank-slate", text: "No matches" assert_select ".search__blank-slate", text: "No matches"
end end
test "search as JSON" do
get search_path(q: "broken", script_name: "/#{@account.external_account_id}"), as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
assert_equal 1, body.size
assert_equal "Layout is broken", body.first["title"]
end
test "search by card ID as JSON returns array" do
get search_path(q: @card.id, script_name: "/#{@account.external_account_id}"), as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
assert_equal 1, body.size
assert_equal @card.id, body.first["id"]
end
test "search as JSON deduplicates cards with multiple search hits" do
get search_path(q: "haggis", script_name: "/#{@account.external_account_id}"), as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
assert_equal 1, body.size
assert_equal @comment2_card.id, body.first["id"]
end
test "search highlights matched terms with proper HTML marks" do test "search highlights matched terms with proper HTML marks" do
@board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user) @board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user)
@@ -49,6 +49,11 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to users(:david) assert_redirected_to users(:david)
end end
test "delete self as JSON" do
delete user_avatar_path(users(:david)), as: :json
assert_response :no_content
end
test "unable to delete other" do test "unable to delete other" do
delete user_avatar_path(users(:kevin)) delete user_avatar_path(users(:kevin))
assert_response :forbidden assert_response :forbidden
@@ -20,6 +20,42 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent
end end
test "create as JSON" do
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
post user_push_subscriptions_path(users(:david)),
params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }, as: :json
assert_response :created
end
test "create as JSON for duplicate subscription" do
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
users(:david).push_subscriptions.create!(subscription_params)
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_path(users(:david)),
params: { push_subscription: subscription_params }, as: :json
end
assert_response :created
end
test "destroy as JSON" do
subscription = users(:david).push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "123",
auth_key: "456"
)
assert_difference -> { Push::Subscription.count }, -1 do
delete user_push_subscription_path(users(:david), subscription), as: :json
end
assert_response :no_content
end
test "destroy a push subscription" do test "destroy a push subscription" do
subscription = users(:david).push_subscriptions.create!( subscription = users(:david).push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/abc123", endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
@@ -14,6 +14,13 @@ class Users::RolesControllerTest < ActionDispatch::IntegrationTest
assert users(:david).reload.admin? assert users(:david).reload.admin?
end end
test "update as JSON" do
put user_role_path(users(:david)), params: { user: { role: "admin" } }, as: :json
assert_response :no_content
assert users(:david).reload.admin?
end
test "can't promote to special roles" do test "can't promote to special roles" do
assert_no_changes -> { users(:david).reload.role } do assert_no_changes -> { users(:david).reload.role } do
put user_role_path(users(:david)), params: { user: { role: "system" } } put user_role_path(users(:david)), params: { user: { role: "system" } }