diff --git a/app/controllers/api/v1/grid_characters_controller.rb b/app/controllers/api/v1/grid_characters_controller.rb index 6741719..a9df085 100644 --- a/app/controllers/api/v1/grid_characters_controller.rb +++ b/app/controllers/api/v1/grid_characters_controller.rb @@ -2,73 +2,117 @@ module Api module V1 + ## + # Controller handling API requests related to grid characters within a party. + # + # This controller provides endpoints for creating, updating, resolving conflicts, + # updating uncap levels, and deleting grid characters. It follows the structure of + # GridSummonsController and GridWeaponsController by using the new authorization method + # `authorize_party_edit!` and deprecating legacy methods such as `set` in favor of + # `find_party`, `find_grid_character`, and `find_incoming_character`. + # + # @see Api::V1::ApiController for shared API behavior. class GridCharactersController < Api::V1::ApiController - attr_reader :party, :incoming_character, :current_characters - - before_action :find_party, only: :create - before_action :set, only: %i[update destroy] - before_action :authorize, only: %i[create update destroy] + before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve] + before_action :find_party, only: %i[create resolve update update_uncap_level destroy] before_action :find_incoming_character, only: :create - before_action :find_current_characters, only: :create + before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy] + ## + # Creates a new grid character. + # + # If a conflicting grid character is found (i.e. one with the same character_id already exists + # in the party), a conflict view is rendered so the user can decide on removal. Otherwise, + # any grid character occupying the desired position is removed and a new one is created. + # + # @return [void] def create - if !conflict_characters.nil? && conflict_characters.length.positive? - # Render a template with the conflicting and incoming characters, - # as well as the selected position, so the user can be presented with - # a decision. + processed_params = transform_character_params(character_params) - # Up to 3 characters can be removed at the same time - conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position]) - render json: conflict_view + if conflict_characters.present? + render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position]) else - # Destroy the grid character in the position if it is already filled - if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists? - character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0] - character.destroy + # Remove any existing grid character occupying the specified position. + if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position])) + existing.destroy end - # Then, create a new grid character - character = GridCharacter.create!(character_params.merge(party_id: party.id, - character_id: incoming_character.id)) + # Build the new grid character + grid_character = build_new_grid_character(processed_params) - if character.save! - grid_character_view = render_grid_character_view(character) - render json: grid_character_view, status: :created + if grid_character.save + render json: GridCharacterBlueprint.render(grid_character, + root: :grid_character, + view: :nested), status: :created + else + render_validation_error_response(grid_character) end end end + ## + # Updates an existing grid character. + # + # Assigns new rings and awakening data to their respective virtual attributes and updates other + # permitted attributes. On success, the updated grid character view is rendered. + # + # @return [void] def update - permitted = character_params.to_h.deep_symbolize_keys - puts "Permitted:" - ap permitted - - # For the new nested structure, assign them to the virtual attributes: - @character.new_rings = permitted[:rings] if permitted[:rings].present? - @character.new_awakening = permitted[:awakening] if permitted[:awakening].present? - - # For the rest of the attributes, you can assign them normally. - @character.assign_attributes(permitted.except(:rings, :awakening)) - - if @character.save - render json: GridCharacterBlueprint.render(@character, view: :nested) + processed_params = transform_character_params(character_params) + assign_raw_attributes(@grid_character) + assign_transformed_attributes(@grid_character, processed_params) + + if @grid_character.save + render json: GridCharacterBlueprint.render(@grid_character, + root: :grid_character, + view: :nested) else - render_validation_error_response(@character) + ap "you are Here" + ap @grid_character.errors + render_validation_error_response(@grid_character) end end + ## + # Updates the uncap level and transcendence step of a grid character. + # + # The grid character's uncap level and transcendence step are updated based on the provided parameters. + # This action requires that the current user is authorized to modify the party. + # + # @return [void] + def update_uncap_level + @grid_character.uncap_level = character_params[:uncap_level] + @grid_character.transcendence_step = character_params[:transcendence_step] + + if @grid_character.save + render json: GridCharacterBlueprint.render(@grid_character, + root: :grid_character, + view: :nested) + else + render_validation_error_response(@grid_character) + end + end + + ## + # Resolves conflicts for grid characters. + # + # This action destroys any conflicting grid characters as well as any grid character occupying + # the target position, then creates a new grid character using a computed default uncap level. + # The default uncap level is determined by the incoming character's attributes. + # + # @return [void] def resolve - incoming = Character.find(resolve_params[:incoming]) - conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) } - party = conflicting.first.party + incoming = Character.find_by(id: resolve_params[:incoming]) + render_not_found_response('character') and return unless incoming - # Destroy each conflicting character - conflicting.each { |character| GridCharacter.destroy(character.id) } + conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact + conflicting.each(&:destroy) - # Destroy the character at the desired position if it exists - existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first - GridCharacter.destroy(existing_character.id) if existing_character + if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position])) + existing.destroy + end + # Compute the default uncap level based on the incoming character's flags. if incoming.special uncap_level = 3 uncap_level = 5 if incoming.ulb @@ -79,33 +123,146 @@ module Api uncap_level = 5 if incoming.flb end - character = GridCharacter.create!(party_id: party.id, character_id: incoming.id, - position: resolve_params[:position], uncap_level: uncap_level) - render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save! + grid_character = GridCharacter.create!( + party_id: @party.id, + character_id: incoming.id, + position: resolve_params[:position], + uncap_level: uncap_level + ) + render json: GridCharacterBlueprint.render(grid_character, + root: :grid_character, + view: :nested), status: :created end - def update_uncap_level - character = GridCharacter.find(character_params[:id]) - - render_unauthorized_response if current_user && (character.party.user != current_user) - - character.uncap_level = character_params[:uncap_level] - character.transcendence_step = character_params[:transcendence_step] - return unless character.save! - - render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character) - end - - # TODO: Implement removing characters + ## + # Destroys a grid character. + # + # If the current user is not the owner of the party, an unauthorized response is rendered. + # On successful destruction, the destroyed grid character view is rendered. + # + # @return [void] def destroy - render_unauthorized_response if @character.party.user != current_user - return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy + grid_character = GridCharacter.find_by('id = ?', params[:id]) + + return render_not_found_response('grid_character') if grid_character.nil? + + render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy end private + ## + # Builds a new grid character using the transformed parameters. + # + # @param processed_params [Hash] the transformed parameters. + # @return [GridCharacter] the newly built grid character. + def build_new_grid_character(processed_params) + grid_character = GridCharacter.new( + character_params.except(:rings, :awakening).merge( + party_id: @party.id, + character_id: @incoming_character.id + ) + ) + assign_transformed_attributes(grid_character, processed_params) + assign_raw_attributes(grid_character) + grid_character + end + + ## + # Assigns raw attributes from the original parameters to the grid character. + # + # These attributes (like new_rings and new_awakening) are used by model callbacks. + # + # @param grid_character [GridCharacter] the grid character instance. + # @return [void] + def assign_raw_attributes(grid_character) + grid_character.new_rings = character_params[:rings] if character_params[:rings].present? + grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present? + grid_character.assign_attributes(character_params.except(:rings, :awakening)) + end + + ## + # Assigns transformed attributes (such as uncap_level, transcendence_step, etc.) to the grid character. + # + # @param grid_character [GridCharacter] the grid character instance. + # @param processed_params [Hash] the transformed parameters. + # @return [void] + def assign_transformed_attributes(grid_character, processed_params) + grid_character.uncap_level = processed_params[:uncap_level] if processed_params[:uncap_level] + grid_character.transcendence_step = processed_params[:transcendence_step] if processed_params[:transcendence_step] + grid_character.perpetuity = processed_params[:perpetuity] if processed_params.key?(:perpetuity) + grid_character.earring = processed_params[:earring] if processed_params[:earring] + + return unless processed_params[:awakening_id] + + grid_character.awakening_id = processed_params[:awakening_id] + grid_character.awakening_level = processed_params[:awakening_level] + end + + ## + # Transforms the incoming character parameters to the required format. + # + # The frontend sends parameters in a raw format that need to be processed (e.g., converting string + # values to integers, handling nested attributes for rings and awakening). This method extracts and + # converts only the keys that were provided. + # + # @param raw_params [ActionController::Parameters] the raw permitted parameters. + # @return [Hash] the transformed parameters. + def transform_character_params(raw_params) + # Convert to a symbolized hash for convenience. + raw = raw_params.to_h.deep_symbolize_keys + + # Only update keys that were provided. + transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) + transformed[:uncap_level] = raw[:uncap_level] if raw[:uncap_level].present? + transformed[:transcendence_step] = raw[:transcendence_step] if raw[:transcendence_step].present? + + # Process rings if provided. + transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? + + # Process earring if provided. + transformed[:earring] = raw[:earring] if raw[:earring].present? + + # Process awakening if provided. + if raw[:awakening].present? + transformed[:awakening_id] = raw[:awakening][:id] + # Default to 1 if level is missing (to satisfy validations) + transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level] : 1 + end + + transformed + end + + ## + # Transforms the rings data to ensure exactly four rings are present. + # + # Pads the array with a default ring hash if necessary. + # + # @param rings [Array, Hash] the rings data from the frontend. + # @return [Hash] a hash with keys :ring1, :ring2, :ring3, :ring4. + def transform_rings(rings) + default_ring = { modifier: nil, strength: nil } + # Ensure rings is an array of hashes. + rings_array = Array(rings).map(&:to_h) + # Pad the array to exactly four rings if needed. + rings_array.fill(default_ring, rings_array.size...4) + { + ring1: rings_array[0], + ring2: rings_array[1], + ring3: rings_array[2], + ring4: rings_array[3] + } + end + + ## + # Returns any grid characters in the party that conflict with the incoming character. + # + # Conflict is defined as any grid character already in the party with the same character_id as the + # incoming character. This method is used to prompt the user for conflict resolution. + # + # @return [Array] def conflict_characters - @conflict_characters ||= find_conflict_characters(incoming_character) + @party.characters.where(character_id: @incoming_character.id).to_a end def find_conflict_characters(incoming_character) @@ -127,67 +284,102 @@ module Api end.flatten end - def set - @character = GridCharacter.includes(:awakening).find(params[:id]) - end - - def find_incoming_character - @incoming_character = Character.find(character_params[:character_id]) - end - + ## + # Finds and sets the party based on parameters. + # + # Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party + # associated with the current grid character. Renders a not found response if the party is missing. + # + # @return [void] def find_party - @party = Party.find(character_params[:party_id]) - render_unauthorized_response if current_user && (party.user != current_user) + @party = Party.find_by(id: params.dig(:character, :party_id)) || + Party.find_by(id: params[:party_id]) || + @grid_character&.party + render_not_found_response('party') unless @party end - def authorize - # Create - unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key) - unauthorized_update = @character && @character.party && (@character.party.user != current_user || @character.party.edit_key != edit_key) - - render_unauthorized_response if unauthorized_create || unauthorized_update + ## + # Finds and sets the grid character based on the provided parameters. + # + # Searches for a grid character by its ID and renders a not found response if it is absent. + # + # @return [void] + def find_grid_character + grid_character_id = params[:id] || params.dig(:character, :id) || params.dig(:resolve, :conflicting) + @grid_character = GridCharacter.includes(:awakening).find_by(id: grid_character_id) + render_not_found_response('grid_character') unless @grid_character end - def transform_character_params(raw_params) - # Convert to a symbolized hash for convenience. - raw = raw_params.deep_symbolize_keys + ## + # Finds and sets the incoming character based on the provided parameters. + # + # Searches for a character using the :character_id parameter and renders a not found response if it is absent. + # + # @return [void] + def find_incoming_character + @incoming_character = Character.find_by(id: character_params[:character_id]) + render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character + end - # Only update keys that were provided. - transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) - transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present? - transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present? - - # Process rings if provided. - transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? - - # Process earring if provided. - transformed[:earring] = raw[:earring] if raw[:earring].present? - - # Process awakening if provided. - if raw[:awakening].present? - transformed[:awakening_id] = raw[:awakening][:id] - # Default to 1 if level is missing (to satisfy validations) - transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1 + ## + # Authorizes the current action by ensuring that the current user or provided edit key + # matches the party's owner. + # + # For parties associated with a user, it verifies that the current user is the owner. + # For anonymous parties, it compares the provided edit key with the party's edit key. + # + # @return [void] + def authorize_party_edit! + if @party.user.present? + authorize_user_party + else + authorize_anonymous_party end - - transformed end - def transform_rings(rings) - default_ring = { modifier: nil, strength: nil } - # Ensure rings is an array of hashes. - rings_array = Array(rings).map(&:to_h) - # Pad the array to exactly four rings if needed. - rings_array.fill(default_ring, rings_array.size...4) - { - ring1: rings_array[0], - ring2: rings_array[1], - ring3: rings_array[2], - ring4: rings_array[3] - } + ## + # Authorizes an action for a party that belongs to a user. + # + # Renders an unauthorized response unless the current user is present and matches the party's user. + # + # @return [void] + def authorize_user_party + return if current_user.present? && @party.user == current_user + + render_unauthorized_response end - # Specify whitelisted properties that can be modified. + ## + # Authorizes an action for an anonymous party using an edit key. + # + # Compares the provided edit key with the party's edit key and renders an unauthorized response + # if they do not match. + # + # @return [void] + def authorize_anonymous_party + provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8') + party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8') + return if valid_edit_key?(provided_edit_key, party_edit_key) + + render_unauthorized_response + end + + ## + # Validates that the provided edit key matches the party's edit key. + # + # @param provided_edit_key [String] the edit key provided in the request. + # @param party_edit_key [String] the edit key associated with the party. + # @return [Boolean] true if the keys match; false otherwise. + def valid_edit_key?(provided_edit_key, party_edit_key) + provided_edit_key.present? && + provided_edit_key.bytesize == party_edit_key.bytesize && + ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) + end + + ## + # Specifies and permits the allowed character parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def character_params params.require(:character).permit( :id, @@ -203,21 +395,13 @@ module Api ) end + ## + # Specifies and permits the allowed resolve parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def resolve_params params.require(:resolve).permit(:position, :incoming, conflicting: []) end - - def render_conflict_view(conflict_characters, incoming_character, incoming_position) - ConflictBlueprint.render(nil, - view: :characters, - conflict_characters: conflict_characters, - incoming_character: incoming_character, - incoming_position: incoming_position) - end - - def render_grid_character_view(grid_character) - GridCharacterBlueprint.render(grid_character, view: :nested) - end end end end diff --git a/spec/requests/grid_characters_controller_spec.rb b/spec/requests/grid_characters_controller_spec.rb index 25f2c33..459a984 100644 --- a/spec/requests/grid_characters_controller_spec.rb +++ b/spec/requests/grid_characters_controller_spec.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true + require 'rails_helper' RSpec.describe 'GridCharacters API', type: :request do let(:user) { create(:user) } let(:party) { create(:party, user: user, edit_key: 'secret') } - # Use canonical records seeded into your DB. - # For example, assume Rosamia (granblue_id "3040087000") and Seofon (granblue_id "3040036000") - let(:rosamia) { Character.find_by(granblue_id: '3040087000') } - let(:seofon) { Character.find_by(granblue_id: '3040036000') } let(:access_token) do Doorkeeper::AccessToken.create!( resource_owner_id: user.id, @@ -18,196 +15,370 @@ RSpec.describe 'GridCharacters API', type: :request do let(:headers) do { 'Authorization' => "Bearer #{access_token.token}", - 'Content-Type' => 'application/json', - 'X-Edit-Key' => 'secret' + 'Content-Type' => 'application/json' } end - describe 'POST /api/v1/characters (create)' do - context 'when creating a grid character with a unique canonical character (e.g. Seofon)' do + # Using canonical data from CSV for non-user-generated models. + let(:incoming_character) { Character.find_by(granblue_id: '3040036000') } + + describe 'Authorization for editing grid characters' do + context 'when the party is owned by a logged in user' do let(:valid_params) do { character: { party_id: party.id, - character_id: seofon.id, - position: 0, + character_id: incoming_character.id, + position: 1, uncap_level: 3, transcendence_step: 0, - rings: [ - { modifier: 'A', strength: 1 }, - { modifier: 'B', strength: 2 } - ] + perpetuity: false, + rings: [{ modifier: '1', strength: '1500' }], + awakening: { id: 'character-balanced', level: 1 } } } end - it 'creates the grid character and returns status created' do + it 'allows the owner to create a grid character' do expect do post '/api/v1/characters', params: valid_params.to_json, headers: headers end.to change(GridCharacter, :count).by(1) expect(response).to have_http_status(:created) - json_response = JSON.parse(response.body) - expect(json_response).to include('position' => 0) - end - end - - context 'when attempting to add a duplicate canonical character (e.g. Rosamia)' do - before do - # Create an initial grid character for Rosamia. - GridCharacter.create!( - party_id: party.id, - character_id: rosamia.id, - position: 1, - uncap_level: 3, - transcendence_step: 0 - ) end - let(:duplicate_params) do - { + it 'allows the owner to update a grid character' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 2, + uncap_level: 3, + transcendence_step: 0) + update_params = { character: { + id: grid_character.id, party_id: party.id, - character_id: rosamia.id, # same canonical character + character_id: incoming_character.id, position: 2, - uncap_level: 3, - transcendence_step: 0 + uncap_level: 4, + transcendence_step: 1, + rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }], + awakening: { id: 'character-attack', level: 2 } } } - end - it 'detects the conflict and returns a conflict resolution view without adding a duplicate' do - # Here we simulate conflict resolution via the resolve endpoint. - expect do - post '/api/v1/characters/resolve', - params: { resolve: { position: 2, incoming: rosamia.id, conflicting: [GridCharacter.last.id] } }.to_json, - headers: headers - end.to change(GridCharacter, :count).by(0) - expect(response).to have_http_status(:created) + # Use the resource route for update (as defined by resources :grid_characters) + put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) json_response = JSON.parse(response.body) - expect(json_response).to include('position' => 2) - end - end - end - - describe 'PUT /api/v1/characters/:id (update)' do - before do - @grid_character = GridCharacter.create!( - party_id: party.id, - character_id: rosamia.id, - position: 1, - uncap_level: 3, - transcendence_step: 0 - ) - end - - let(:update_params) do - { - character: { - id: @grid_character.id, - party_id: party.id, - character_id: rosamia.id, - position: 1, - uncap_level: 4, - transcendence_step: 0, - rings: [ - { modifier: 'C', strength: 3 } - ] - } - } - end - - it 'updates the grid character and returns the updated record' do - put "/api/v1/grid_characters/#{@grid_character.id}", params: update_params.to_json, headers: headers - expect(response).to have_http_status(:ok) - - json_response = JSON.parse(response.body) - expect(json_response).to include('uncap_level' => 4) - end - end - - describe 'POST /api/v1/characters/update_uncap (update uncap level)' do - context 'for a character that does NOT allow transcendence (e.g. Rosamia)' do - before do - @grid_character = GridCharacter.create!( - party_id: party.id, - character_id: rosamia.id, - position: 2, - uncap_level: 2, - transcendence_step: 0 - ) + expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1) end - let(:update_uncap_params) do - { + it 'allows the owner to update the uncap level and transcendence step' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 3, + uncap_level: 3, + transcendence_step: 0) + update_uncap_params = { character: { - id: @grid_character.id, + id: grid_character.id, party_id: party.id, - character_id: rosamia.id, - uncap_level: 3, - transcendence_step: 0 + character_id: incoming_character.id, + uncap_level: 5, + transcendence_step: 1 } } - end - - it 'caps the uncap level at 4 for a character with flb true' do post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response['grid_character']).to include('uncap_level' => 3) + expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1) + end + + it 'allows the owner to resolve conflicts by replacing an existing grid character' do + # Create a conflicting grid character (same character_id) at the target position. + conflicting_character = create(:grid_character, + party: party, + character: incoming_character, + position: 4, + uncap_level: 3) + resolve_params = { + resolve: { + position: 4, + incoming: incoming_character.id, + conflicting: [conflicting_character.id] + } + } + expect do + post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(0) # one record is destroyed and one is created + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('position' => 4) + expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'allows the owner to destroy a grid character' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 5, + uncap_level: 3) + # Using the custom route for destroy: DELETE '/api/v1/characters' + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) end end - context 'for a character that allows transcendence (e.g. Seofon)' do - before do - # For Seofon, the "transcendence" behavior is enabled by its ulb flag. - @grid_character = GridCharacter.create!( - party_id: party.id, - character_id: seofon.id, - position: 2, - uncap_level: 5, - transcendence_step: 0 - ) + context 'when the party is anonymous (no user)' do + let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } } + let(:valid_params) do + { + character: { + party_id: anon_party.id, + character_id: incoming_character.id, + position: 1, + uncap_level: 3, + transcendence_step: 0, + perpetuity: false, + rings: [{ modifier: '1', strength: '1500' }], + awakening: { id: 'character-balanced', level: 1 } + } + } end + it 'allows anonymous creation with correct edit_key' do + expect do + post '/api/v1/characters', params: valid_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(1) + expect(response).to have_http_status(:created) + end + + context 'when an incorrect edit_key is provided' do + let(:headers) { super().merge('X-Edit-Key' => 'wrong') } + + it 'returns an unauthorized response' do + post '/api/v1/characters', params: valid_params.to_json, headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + end + end + + describe 'POST /api/v1/characters (create action) with invalid parameters' do + context 'with missing or invalid required fields' do + let(:invalid_params) do + { + character: { + party_id: party.id, + # Missing character_id + position: 1, + uncap_level: 2, + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error messages' do + post '/api/v1/characters', params: invalid_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + # Verify that the error message on uncap_level includes a specific phrase. + expect(json_response['errors']['code'].to_s).to eq('no_character_provided') + end + end + end + + describe 'PUT /api/v1/grid_characters/:id (update action)' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid parameters' do + let(:update_params) do + { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + position: 2, + uncap_level: 4, + transcendence_step: 1, + rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }], + awakening: { id: 'character-balanced', level: 2 } + } + } + end + + it 'updates the grid character and returns the updated record' do + put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1) + end + end + + context 'with invalid parameters' do + let(:invalid_update_params) do + { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + position: 2, + uncap_level: 'invalid', + transcendence_step: 1 + } + } + end + + it 'returns unprocessable entity status with error details' do + put "/api/v1/grid_characters/#{grid_character.id}", params: invalid_update_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + expect(json_response['errors']['uncap_level'].to_s).to include('is not a number') + end + end + end + + describe 'POST /api/v1/characters/update_uncap (update uncap level action)' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid uncap level parameters' do let(:update_uncap_params) do { character: { - id: @grid_character.id, + id: grid_character.id, party_id: party.id, - character_id: seofon.id, + character_id: incoming_character.id, uncap_level: 5, transcendence_step: 1 } } end - it 'updates the uncap level to 6 when the character supports transcendence via ulb' do + it 'updates the uncap level and transcendence step' do post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1) end end end - describe 'DELETE /api/v1/characters (destroy)' do - before do - @grid_character = GridCharacter.create!( - party_id: party.id, - character_id: rosamia.id, - position: 4, - uncap_level: 3, - transcendence_step: 0 - ) + describe 'POST /api/v1/characters/resolve (conflict resolution action)' do + let!(:conflicting_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 4, + uncap_level: 3) end - it 'destroys the grid character and returns a destroyed view' do + let(:resolve_params) do + { + resolve: { + position: 4, + incoming: incoming_character.id, + conflicting: [conflicting_character.id] + } + } + end + + it 'resolves conflicts by replacing the existing grid character' do + expect(GridCharacter.exists?(conflicting_character.id)).to be true expect do - delete '/api/v1/characters', params: { id: @grid_character.id }.to_json, headers: headers - end.to change(GridCharacter, :count).by(-1) - expect(response).to have_http_status(:ok) + post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(0) # one record deleted, one created + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('position' => 4) + expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'DELETE /api/v1/characters (destroy action)' do + context 'when the party is owned by a logged in user' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 6, + uncap_level: 3) + end + + it 'destroys the grid character and returns a success response' do + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'returns not found when trying to delete a non-existent grid character' do + delete '/api/v1/characters', params: { id: '00000000-0000-0000-0000-000000000000' }.to_json, headers: headers + expect(response).to have_http_status(:not_found) + end + end + + context 'when the party is anonymous' do + let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } } + let!(:grid_character) do + create(:grid_character, + party: anon_party, + character: incoming_character, + position: 6, + uncap_level: 3) + end + + it 'allows anonymous user to destroy the grid character' do + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'prevents deletion when a logged in user attempts to delete an anonymous grid character' do + auth_headers = headers.except('X-Edit-Key') + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: auth_headers + end.not_to change(GridCharacter, :count) + expect(response).to have_http_status(:unauthorized) + end + end + end + + # Debug hook: if any example fails and a response exists, print the error message. + after(:each) do |example| + if example.exception && defined?(response) && response.present? + error_message = begin + JSON.parse(response.body)['exception'] + rescue JSON::ParserError + response.body + end + puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}" + + # Parse once and grab the trace safely + parsed_body = JSON.parse(response.body) + trace = parsed_body.dig('traces', 'Application Trace') + ap trace if trace # Only print if trace is not nil end end end