diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 015ecda1f7..d73b1123ed 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -1,5 +1,5 @@ class CardsController < ApplicationController - wrap_parameters :card, include: %i[ title description image created_at last_active_at ] + wrap_parameters :card, include: %i[ title description image created_at last_active_at tag_ids ] include FilterScoped @@ -20,8 +20,13 @@ def create end format.json do - @card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") - render :show, status: :created, location: card_path(@card, format: :json) + @card = @board.cards.new card_params.merge(creator: Current.user, status: "published") + + if @card.save + render :show, status: :created, location: card_path(@card, format: :json) + else + render json: @card.errors, status: :unprocessable_entity + end end end end @@ -33,11 +38,14 @@ def edit end def update - @card.update! card_params - respond_to do |format| - format.turbo_stream - format.json { render :show } + if @card.update(card_params) + format.turbo_stream + format.json { render :show } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @card.errors, status: :unprocessable_entity } + end end end @@ -68,6 +76,6 @@ def ensure_permission_to_administer_card end def card_params - params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ]) + params.expect(card: [ :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) end end diff --git a/app/models/card/taggable.rb b/app/models/card/taggable.rb index 73c89d0b64..92621a74c0 100644 --- a/app/models/card/taggable.rb +++ b/app/models/card/taggable.rb @@ -6,6 +6,8 @@ module Card::Taggable has_many :tags, through: :taggings scope :tagged_with, ->(tags) { joins(:taggings).where(taggings: { tag: tags }) } + + validate :tags_belong_to_account end def toggle_tag_with(title) @@ -23,4 +25,11 @@ def toggle_tag_with(title) def tagged_with?(tag) tags.include? tag end + + private + def tags_belong_to_account + if tags.any? { it.account_id != account_id } + errors.add(:tags, "must belong to the card account") + end + end end diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb index 77a74f454e..39dc15c616 100644 --- a/test/controllers/api/flat_json_params_test.rb +++ b/test/controllers/api/flat_json_params_test.rb @@ -75,6 +75,21 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest assert_equal "Flat description", card.description.to_plain_text end + test "create card with flat JSON and tag_ids" do + tag = tags(:mobile) + + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { title: "Flat tagged card", tag_ids: [ tag.id ] }, + as: :json + end + + assert_response :created + card = Card.last + assert_equal [ tag ], card.reload.tags + assert_equal [ tag.title ], @response.parsed_body["tags"] + end + test "update card with flat JSON" do card = cards(:logo) @@ -88,6 +103,19 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest assert_equal "Updated flat", card.description.to_plain_text end + test "update card with flat JSON and tag_ids" do + card = cards(:logo) + tag = tags(:mobile) + + put card_path(card), + params: { tag_ids: [ tag.id ] }, + as: :json + + assert_response :success + assert_equal [ tag ], card.reload.tags + assert_equal [ tag.title ], @response.parsed_body["tags"] + end + test "create board with flat JSON" do assert_difference -> { Board.count }, +1 do post boards_path, params: { name: "Flat board" }, as: :json diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index f582aa9c09..0c3e020d73 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -101,6 +101,17 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_no_match "reactions", response.body, "Draft card should not show reactions/boost button" end + test "update as HTML with invalid tag_ids renders edit with unprocessable entity" do + card = cards(:logo) + foreign_tag = accounts(:initech).tags.create!(title: "foreign") + + patch card_path(card), params: { card: { tag_ids: [ foreign_tag.id ] } } + + assert_response :unprocessable_entity + assert_equal [ tags(:web) ], card.reload.tags + assert_match "Close editor and discard changes", response.body + end + test "users can only see cards in boards they have access to" do get card_path(cards(:logo)) assert_response :success @@ -207,6 +218,43 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_equal "Big if true", card.description.to_plain_text end + test "create as JSON with tag_ids applies tags to the created card" do + tag = tags(:mobile) + + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Tagged card", tag_ids: [ tag.id ] } }, + as: :json + assert_response :created + end + + card = Card.last + assert_equal [ tag ], card.reload.tags + assert_equal [ tag.title ], @response.parsed_body["tags"] + end + + test "create as JSON with nonexistent tag_ids returns not found" do + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Tagged card", tag_ids: [ "does-not-exist" ] } }, + as: :json + end + + assert_response :not_found + end + + test "create as JSON with foreign-account tag_ids returns unprocessable entity" do + foreign_tag = accounts(:initech).tags.create!(title: "foreign") + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Tagged card", tag_ids: [ foreign_tag.id ] } }, + as: :json + end + + assert_response :unprocessable_entity + end + test "create as JSON with custom created_at" do custom_time = Time.utc(2024, 1, 15, 10, 30, 0) @@ -293,6 +341,69 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_equal "Update test", card.reload.title end + test "update as JSON with tag_ids updates tags on the card" do + card = cards(:logo) + tag = tags(:mobile) + + put card_path(card, format: :json), params: { card: { tag_ids: [ tag.id ] } } + assert_response :success + + assert_equal [ tag ], card.reload.tags + assert_equal [ tag.title ], @response.parsed_body["tags"] + end + + test "update as JSON without tag_ids preserves existing tags" do + card = cards(:logo) + + put card_path(card, format: :json), params: { card: { title: "Updated title" } } + assert_response :success + + assert_equal [ tags(:web) ], card.reload.tags + assert_equal [ tags(:web).title ], @response.parsed_body["tags"] + end + + test "update as JSON with empty tag_ids clears existing tags" do + card = cards(:logo) + assert_equal [ tags(:web) ], card.tags + + put card_path(card, format: :json), params: { card: { tag_ids: [] } } + assert_response :success + + assert_empty card.reload.tags + assert_empty @response.parsed_body["tags"] + end + + test "update as JSON with foreign-account tag_ids returns unprocessable entity" do + card = cards(:logo) + foreign_tag = accounts(:initech).tags.create!(title: "foreign") + + put card_path(card, format: :json), params: { card: { tag_ids: [ foreign_tag.id ] } } + + assert_response :unprocessable_entity + assert_equal [ tags(:web) ], card.reload.tags + end + + test "update as JSON with description and tag_ids busts the card cache key" do + card = cards(:logo) + original_cache_key = card.cache_key_with_version + tag = tags(:mobile) + + travel 1.minute do + put card_path(card, format: :json), params: { + card: { + description: "Updated description", + tag_ids: [ tag.id ] + } + } + end + + assert_response :success + assert_not_equal original_cache_key, card.reload.cache_key_with_version + assert_equal "Updated description", card.description.to_plain_text.strip + assert_equal [ tag ], card.tags + assert_equal [ tag.title ], @response.parsed_body["tags"] + end + test "delete as JSON" do card = cards(:logo) diff --git a/test/models/card/taggable_test.rb b/test/models/card/taggable_test.rb index 4aadde46f9..67a3844a07 100644 --- a/test/models/card/taggable_test.rb +++ b/test/models/card/taggable_test.rb @@ -25,4 +25,40 @@ class Card::TaggableTest < ActiveSupport::TestCase assert_not_equal cards(:logo).tags.last, cards(:paycheck).tags.last end + + test "updating just tag_ids touches the card and board" do + board = @card.board + + travel 1.minute do + assert_changes -> { @card.reload.updated_at } do + assert_changes -> { board.reload.updated_at } do + @card.update!(tag_ids: [ tags(:web).id, tags(:mobile).id ]) + end + end + end + end + + test "updating tag_ids with an empty array clears tags" do + assert_equal [ tags(:web) ], @card.tags + + @card.update!(tag_ids: []) + + assert_empty @card.reload.tags + end + + test "updating tag_ids raises when a tag does not exist" do + assert_raises(ActiveRecord::RecordNotFound) do + @card.update!(tag_ids: [ "does-not-exist" ]) + end + end + + test "updating tag_ids is invalid when the tag belongs to another account" do + foreign_tag = accounts(:initech).tags.create!(title: "foreign") + + error = assert_raises(ActiveRecord::RecordInvalid) do + @card.update!(tag_ids: [ foreign_tag.id ]) + end + + assert_includes error.record.errors[:tags], "must belong to the card account" + end end