From 9a54363a7f53910ed185da044ef4dcdde96da1c4 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 10 Feb 2025 18:14:29 -0800 Subject: [PATCH] Refactor GridWeaponsController - Refactors controller - Adds YARD documentation - Adds Rspec tests --- .../api/v1/grid_weapons_controller.rb | 458 +++++++++++------- spec/requests/grid_weapons_controller_spec.rb | 363 ++++++++++++++ 2 files changed, 656 insertions(+), 165 deletions(-) create mode 100644 spec/requests/grid_weapons_controller_spec.rb diff --git a/app/controllers/api/v1/grid_weapons_controller.rb b/app/controllers/api/v1/grid_weapons_controller.rb index aebf903..f7a719d 100644 --- a/app/controllers/api/v1/grid_weapons_controller.rb +++ b/app/controllers/api/v1/grid_weapons_controller.rb @@ -2,110 +2,143 @@ module Api module V1 + ## + # Controller handling API requests related to grid weapons within a party. + # + # This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid weapons. + # It ensures that the correct party and weapon are found and that the current user (or edit key) is authorized. + # + # @see Api::V1::ApiController for shared API behavior. class GridWeaponsController < Api::V1::ApiController - attr_reader :party, :incoming_weapon - - before_action :set, except: %w[create update_uncap_level] - before_action :find_party, only: :create - before_action :find_incoming_weapon, only: :create - before_action :authorize, only: %i[create update destroy] + before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy] + before_action :find_party, only: %i[create update update_uncap_level resolve destroy] + before_action :find_incoming_weapon, only: %i[create resolve] + before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy] + ## + # Creates a new GridWeapon. + # + # Builds a new GridWeapon using parameters merged with the party and weapon IDs. + # If the model validations (including compatibility and conflict validations) + # pass, the weapon is saved; otherwise, conflict resolution is attempted. + # + # @return [void] def create - # Create the GridWeapon with the desired parameters - weapon = GridWeapon.new - weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id) + return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil? - if weapon.validate - save_weapon(weapon) + grid_weapon = GridWeapon.new( + weapon_params.merge( + party_id: @party.id, + weapon_id: @incoming_weapon.id + ) + ) + + if grid_weapon.valid? + save_weapon(grid_weapon) else - handle_conflict(weapon) + if grid_weapon.errors[:series].include?('must not conflict with existing weapons') + handle_conflict(grid_weapon) + else + render_validation_error_response(grid_weapon) + end end end + ## + # Updates an existing GridWeapon. + # + # After checking authorization, assigns new attributes to the weapon. + # Also normalizes modifier and strength fields, then renders the updated view on success. + # + # @return [void] + def update + normalize_ax_fields! + if @grid_weapon.update(weapon_params) + render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok + else + render_validation_error_response(@grid_weapon) + end + end + + ## + # Updates the uncap level and transcendence step of a GridWeapon. + # + # Finds the weapon to update, computes the maximum allowed uncap level based on its associated + # weapon’s flags, and then updates the fields accordingly. + # + # @return [void] + def update_uncap_level + max_uncap = compute_max_uncap_level(@grid_weapon.weapon) + requested_uncap = weapon_params[:uncap_level].to_i + new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap + + if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i) + render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok + else + render_validation_error_response(@grid_weapon) + end + end + + ## + # Resolves conflicts by removing conflicting grid weapons and creating a new one. + # + # Expects resolve parameters that include the desired position, the incoming weapon ID, + # and a list of conflicting GridWeapon IDs. After deleting conflicting records and any existing + # grid weapon at that position, creates a new GridWeapon with computed uncap_level. + # + # @return [void] def resolve - incoming = Weapon.find(resolve_params[:incoming]) - conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) } - party = conflicting.first.party + incoming = Weapon.find_by(id: resolve_params[:incoming]) + conflicting_ids = resolve_params[:conflicting] + conflicting_weapons = GridWeapon.where(id: conflicting_ids) # Destroy each conflicting weapon - conflicting.each { |weapon| GridWeapon.destroy(weapon.id) } + conflicting_weapons.each(&:destroy) # Destroy the weapon at the desired position if it exists - existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first - GridWeapon.destroy(existing_weapon.id) if existing_weapon + if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position])) + existing_weapon.destroy + end - uncap_level = 3 - uncap_level = 4 if incoming.flb - uncap_level = 5 if incoming.ulb - - weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id, - position: resolve_params[:position], uncap_level: uncap_level) - - return unless weapon.save - - view = render_grid_weapon_view(weapon, resolve_params[:position]) - render json: view, status: :created - end - - def update - render_unauthorized_response if current_user && (@weapon.party.user != current_user) - - # TODO: Server-side validation of weapon mods - # We don't want someone modifying the JSON and adding - # keys to weapons that cannot have them - - # Maybe we make methods on the model to validate for us somehow - - @weapon.assign_attributes(weapon_params) - - @weapon.ax_modifier1 = nil if weapon_params[:ax_modifier1] == -1 - @weapon.ax_modifier2 = nil if weapon_params[:ax_modifier2] == -1 - @weapon.ax_strength1 = nil if weapon_params[:ax_strength1]&.zero? - @weapon.ax_strength2 = nil if weapon_params[:ax_strength2]&.zero? - - render json: GridWeaponBlueprint.render(@weapon, view: :nested) if @weapon.save - end - - def destroy - render_unauthorized_response if @weapon.party.user != current_user - return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy - end - - def update_uncap_level - weapon = GridWeapon.find(weapon_params[:id]) - object = weapon.weapon - max_uncap_level = max_uncap_level(object) - - render_unauthorized_response if current_user && (weapon.party.user != current_user) - - greater_than_max_uncap = weapon_params[:uncap_level].to_i > max_uncap_level - can_be_transcended = object.transcendence && weapon_params[:transcendence_step] && weapon_params[:transcendence_step]&.to_i&.positive? - - uncap_level = if greater_than_max_uncap || can_be_transcended - max_uncap_level - else - weapon_params[:uncap_level] - end - - transcendence_step = if object.transcendence && weapon_params[:transcendence_step] - weapon_params[:transcendence_step] - else - 0 - end - - weapon.update!( - uncap_level: uncap_level, - transcendence_step: transcendence_step + # Compute the default uncap level based on incoming weapon flags, maxing out at ULB. + new_uncap = compute_default_uncap(incoming) + grid_weapon = GridWeapon.create!( + party_id: @party.id, + weapon_id: incoming.id, + position: resolve_params[:position], + uncap_level: new_uncap, + transcendence_step: 0 ) - return unless weapon.persisted? + if grid_weapon.persisted? + render json: GridWeaponBlueprint.render(grid_weapon, view: :full, root: :grid_weapon, meta: { replaced: resolve_params[:position] }), status: :created + else + render_validation_error_response(grid_weapon) + end + end - render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon) + ## + # Destroys a GridWeapon. + # + # Checks authorization and, if allowed, destroys the weapon and renders the destroyed view. + # + # @return [void] + def destroy + grid_weapon = GridWeapon.find_by('id = ?', params[:id]) + + return render_not_found_response('grid_weapon') if grid_weapon.nil? + + render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy end private - def max_uncap_level(weapon) + ## + # Computes the maximum uncap level for a given weapon based on its flags. + # + # @param weapon [Weapon] the associated weapon. + # @return [Integer] the maximum allowed uncap level. + def compute_max_uncap_level(weapon) if weapon.flb && !weapon.ulb && !weapon.transcendence 4 elsif weapon.ulb && !weapon.transcendence @@ -117,122 +150,213 @@ module Api end end - def check_weapon_compatibility - return if compatible_with_position?(incoming_weapon, weapon_params[:position]) - - raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon) + ## + # Computes the default uncap level for an incoming weapon. + # + # This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags. + # + # @param incoming [Weapon] the incoming weapon. + # @return [Integer] the default uncap level. + def compute_default_uncap(incoming) + compute_max_uncap_level(incoming) end - # Check if the incoming weapon is compatible with the specified position - def compatible_with_position?(incoming_weapon, position) - false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series) - true - end - - def conflict_weapon - @conflict_weapon ||= find_conflict_weapon(party, incoming_weapon) - end - - # Find a conflict weapon if one exists - def find_conflict_weapon(party, incoming_weapon) - return unless incoming_weapon.limit - - party.weapons.find do |weapon| - series_match = incoming_weapon.series == weapon.weapon.series - weapon if series_match || opus_or_draconic?(weapon.weapon) && opus_or_draconic?(incoming_weapon) - end - end - - def find_incoming_weapon - @incoming_weapon = Weapon.find_by(id: weapon_params[:weapon_id]) - end - - def find_party - # BUG: I can create grid weapons even when I'm not logged in on an authenticated party - @party = Party.find(weapon_params[:party_id]) - render_unauthorized_response if current_user && (party.user != current_user) - end - - def opus_or_draconic?(weapon) - [2, 3].include?(weapon.series) - end - - # Render the conflict view as a string - def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position) - ConflictBlueprint.render(nil, view: :weapons, - conflict_weapons: conflict_weapons, - incoming_weapon: incoming_weapon, - incoming_position: incoming_position) + ## + # Normalizes the AX modifier fields for the weapon parameters. + # + # Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1. + # + # @return [void] + def normalize_ax_fields! + params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1 + + params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1 end + ## + # Renders the grid weapon view. + # + # @param grid_weapon [GridWeapon] the grid weapon to render. + # @param conflict_position [Integer] the position that was replaced. + # @return [String] the rendered view. def render_grid_weapon_view(grid_weapon, conflict_position) - GridWeaponBlueprint.render(grid_weapon, view: :full, - root: :grid_weapon, - meta: { replaced: conflict_position }) + GridWeaponBlueprint.render(grid_weapon, + view: :full, + root: :grid_weapon, + meta: { replaced: conflict_position }) end + ## + # Saves the GridWeapon. + # + # Deletes any existing grid weapon at the same position, + # adjusts party attributes based on the weapon's position, + # and renders the full view upon successful save. + # + # @param weapon [GridWeapon] the grid weapon to save. + # @return [void] def save_weapon(weapon) - # Check weapon validation and delete existing grid weapon - # if one already exists at position - if (grid_weapon = GridWeapon.where( - party_id: party.id, - position: weapon_params[:position] - ).first) - GridWeapon.destroy(grid_weapon.id) + # Check weapon validation and delete existing grid weapon if one already exists at position + if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position)) + existing.destroy end # Set the party's element if the grid weapon is being set as mainhand - if weapon.position == -1 - party.element = weapon.weapon.element - party.save! - elsif [9, 10, 11].include?(weapon.position) - party.extra = true - party.save! + if weapon.position.to_i == -1 + @party.element = weapon.weapon.element + @party.save! + elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i) + @party.extra = true + @party.save! end - # Render the weapon if it can be saved - return unless weapon.save - - output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon) - render json: output, status: :created + if weapon.save + output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon) + render json: output, status: :created + else + render_validation_error_response(weapon) + end end + ## + # Handles conflicts when a new GridWeapon fails validation. + # + # Retrieves the array of conflicting grid weapons (via the model’s conflicts method) + # and either renders a conflict view (if the canonical weapons differ) or updates the + # conflicting grid weapon's position. + # + # @param weapon [GridWeapon] the weapon that failed validation. + # @return [void] def handle_conflict(weapon) conflict_weapons = weapon.conflicts(party) + # Find if one of the conflicting grid weapons is associated with the incoming weapon. + conflict_weapon = conflict_weapons.find { |gw| gw.weapon.id == incoming_weapon.id } - # Map conflict weapon IDs into an array - conflict_weapon_ids = conflict_weapons.map(&:id) - if !conflict_weapon_ids.include?(incoming_weapon.id) - # Render conflict view if the underlying canonical weapons - # are not identical + if conflict_weapon.nil? output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position]) render json: output else - # Move the original grid weapon to the new position - # to preserve keys and other modifications old_position = conflict_weapon.position conflict_weapon.position = weapon_params[:position] - if conflict_weapon.save output = render_grid_weapon_view(conflict_weapon, old_position) render json: output + else + render_validation_error_response(conflict_weapon) end end end - def set - @weapon = GridWeapon.where('id = ?', params[:id]).first + ## + # Renders the conflict view. + # + # @param conflict_weapons [Array] an array of conflicting grid weapons. + # @param incoming_weapon [Weapon] the incoming weapon. + # @param incoming_position [Integer] the desired position. + # @return [String] the rendered conflict view. + def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position) + ConflictBlueprint.render(nil, + view: :weapons, + conflict_weapons: conflict_weapons, + incoming_weapon: incoming_weapon, + incoming_position: incoming_position) end - def authorize - # Create - unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key) - unauthorized_update = @weapon && @weapon.party && (@weapon.party.user != current_user || @weapon.party.edit_key != edit_key) - - render_unauthorized_response if unauthorized_create || unauthorized_update + ## + # Finds and sets the GridWeapon based on the provided parameters. + # + # Searches for a grid weapon using various parameter keys and renders a not found response if it is absent. + # + # @return [void] + def find_grid_weapon + grid_weapon_id = params[:id] || params.dig(:weapon, :id) || params.dig(:resolve, :conflicting) + @grid_weapon = GridWeapon.find_by(id: grid_weapon_id) + render_not_found_response('grid_weapon') unless @grid_weapon end - # Specify whitelisted properties that can be modified. + ## + # Finds and sets the incoming weapon. + # + # @return [void] + def find_incoming_weapon + if params.dig(:weapon, :weapon_id).present? + @incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id)) + render_not_found_response('weapon') unless @incoming_weapon + else + @incoming_weapon = nil + end + end + + ## + # Finds and sets the party based on parameters. + # + # Renders an unauthorized response if the current user is not the owner. + # + # @return [void] + def find_party + @party = Party.find_by(id: params.dig(:weapon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_weapon&.party + render_not_found_response('party') unless @party + end + + ## + # 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 checks that the provided edit key matches the party's edit key. + # + # @return [void] + def authorize_party_edit! + if @party.user.present? + authorize_user_party + else + authorize_anonymous_party + end + end + + ## + # 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 + + return render_unauthorized_response + end + + ## + # Authorizes an action for an anonymous party using an edit key. + # + # Retrieves and normalizes the provided edit key and compares it with the party's edit key. + # Renders an unauthorized response unless the keys are valid. + # + # @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) + + return 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 edit 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 weapon parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def weapon_params params.require(:weapon).permit( :id, :party_id, :weapon_id, @@ -243,6 +367,10 @@ module Api ) end + ## + # Specifies and permits the resolve parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def resolve_params params.require(:resolve).permit(:position, :incoming, conflicting: []) end diff --git a/spec/requests/grid_weapons_controller_spec.rb b/spec/requests/grid_weapons_controller_spec.rb new file mode 100644 index 0000000..78e8db0 --- /dev/null +++ b/spec/requests/grid_weapons_controller_spec.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe 'GridWeapons API', type: :request do + let(:user) { create(:user) } + # By default, we create a party owned by the user with edit_key 'secret' + let(:party) { create(:party, user: user, edit_key: 'secret') } + let(:access_token) do + Doorkeeper::AccessToken.create!( + resource_owner_id: user.id, + expires_in: 30.days, + scopes: 'public' + ) + end + let(:headers) do + { + 'Authorization' => "Bearer #{access_token.token}", + 'Content-Type' => 'application/json', + } + end + let(:weapon) { Weapon.find_by!(granblue_id: '1040611300') } + let(:incoming_weapon) { Weapon.find_by!(granblue_id: '1040912100') } + + describe 'Authorization for editing grid weapons' do + context 'when the party is owned by a logged in user' do + let(:weapon_params) do + { + weapon: { + party_id: party.id, + weapon_id: weapon.id, + position: 0, + mainhand: true, + uncap_level: 3, + transcendence_step: 0, + element: weapon.element, + } + } + end + + it 'allows the owner to create a grid weapon' do + expect do + post '/api/v1/weapons', params: weapon_params.to_json, headers: headers + end.to change(GridWeapon, :count).by(1) + expect(response).to have_http_status(:created) + end + + it 'rejects a logged-in user that does not own the party' do + # Create a party owned by a different user. + other_user = create(:user) + party_owned_by_other = create(:party, user: other_user, edit_key: 'secret') + weapon_params[:weapon][:party_id] = party_owned_by_other.id + post '/api/v1/weapons', params: weapon_params.to_json, headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the party is anonymous (no user)' do + # Override party to be anonymous with its own edit_key. + let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') } + let(:party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:anon_params) do + { + weapon: { + party_id: party.id, + weapon_id: weapon.id, + position: 0, + mainhand: true, + uncap_level: 3, + transcendence_step: 0, + element: weapon.element, + } + } + end + + it 'allows editing with correct edit_key' do + expect { post '/api/v1/weapons', params: anon_params.to_json, headers: headers } + .to change(GridWeapon, :count).by(1) + expect(response).to have_http_status(:created) + end + + context 'when an incorrect edit_key is provided' do + # Override the edit_key (simulate invalid key) + let(:headers) { super().merge('X-Edit-Key' => 'wrong') } + + it 'returns an unauthorized response' do + post '/api/v1/weapons', params: anon_params.to_json, headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + end + end + + describe 'POST /api/v1/weapons (create action)' do + context 'with valid parameters' do + let(:valid_params) do + { + weapon: { + party_id: party.id, + weapon_id: weapon.id, + position: 0, + mainhand: true, + uncap_level: 3, + transcendence_step: 0, + element: weapon.element, + weapon_key1_id: nil, + weapon_key2_id: nil, + weapon_key3_id: nil, + ax_modifier1: nil, + ax_modifier2: nil, + ax_strength1: nil, + ax_strength2: nil, + awakening_id: nil, + awakening_level: 1, + } + } + end + + it 'creates a grid weapon and returns status created' do + expect { post '/api/v1/weapons', params: valid_params.to_json, headers: headers } + .to change(GridWeapon, :count).by(1) + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_weapon') + expect(json_response['grid_weapon']).to include('position' => 0) + end + end + + context 'with invalid parameters' do + let(:invalid_params) do + { + weapon: { + party_id: party.id, + weapon_id: nil, # Missing required weapon_id + position: 0, + mainhand: true, + uncap_level: 3, + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with errors' do + post '/api/v1/weapons', 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') + end + end + + context 'when unauthorized (invalid edit key)' do + # For this test, use an anonymous party so that edit key checking is applied. + let(:party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:valid_params) do + { + weapon: { + party_id: party.id, + weapon_id: weapon.id, + position: 0, + mainhand: true, + uncap_level: 3, + transcendence_step: 0, + element: weapon.element, + } + } + end + + let(:unauthorized_headers) { headers.merge('X-Edit-Key' => 'wrong') } + + it 'returns an unauthorized response' do + post '/api/v1/weapons', params: valid_params.to_json, headers: unauthorized_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/grid_weapons/:id (update action)' do + let!(:grid_weapon) do + create(:grid_weapon, + party: party, + weapon: weapon, + position: 2, + uncap_level: 3, + transcendence_step: 0, + mainhand: false) + end + let(:update_params) do + { + weapon: { + id: grid_weapon.id, + party_id: party.id, + weapon_id: weapon.id, + position: 2, + mainhand: false, + uncap_level: 4, + transcendence_step: 1, + element: weapon.element, + weapon_key1_id: nil, + weapon_key2_id: nil, + weapon_key3_id: nil, + ax_modifier1: nil, + ax_modifier2: nil, + ax_strength1: nil, + ax_strength2: nil, + awakening_id: nil, + awakening_level: 1 + } + } + end + + it 'updates the grid weapon and returns the updated record' do + put "/api/v1/grid_weapons/#{grid_weapon.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_weapon']).to include('mainhand' => false, 'uncap_level' => 4) + end + end + + describe 'POST /api/v1/weapons/update_uncap (update uncap level action)' do + before do + # For this test, update the weapon so that its conditions dictate a maximum uncap of 5. + weapon.update!(flb: false, ulb: true, transcendence: false) + end + let!(:grid_weapon) do + create(:grid_weapon, + party: party, + weapon: weapon, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + let(:update_uncap_params) do + { + weapon: { + id: grid_weapon.id, # now nested inside the weapon hash + party_id: party.id, + weapon_id: weapon.id, + uncap_level: 6, # attempt above allowed; should be capped at 5 + transcendence_step: 0 + } + } + end + + it 'updates the uncap level to 5 for the grid weapon' do + post '/api/v1/weapons/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_weapon']).to include('uncap_level' => 5) + end + end + + describe 'POST /api/v1/weapons/resolve (conflict resolution action)' do + let!(:conflicting_weapon) do + create(:grid_weapon, + party: party, + weapon: weapon, + position: 5, + uncap_level: 3) + end + + before do + # Set up the incoming weapon with flags such that: default uncap is 3, + # but if flb is true then uncap should become 4. + incoming_weapon.update!(flb: true, ulb: false, transcendence: false) + end + + let(:resolve_params) do + { + resolve: { + position: 5, + incoming: incoming_weapon.id, + conflicting: [conflicting_weapon.id] + } + } + end + + it 'resolves conflicts by destroying conflicting grid weapons and creating a new one' do + expect(GridWeapon.exists?(conflicting_weapon.id)).to be true + + # The net change should be zero: one grid weapon is destroyed and one is created. + expect { post '/api/v1/weapons/resolve', params: resolve_params.to_json, headers: headers } + .to change(GridWeapon, :count).by(0) + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_weapon') + # According to the controller logic, with incoming.flb true, the uncap level should be 4. + expect(json_response['grid_weapon']).to include('uncap_level' => 4) + expect { conflicting_weapon.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'DELETE /api/v1/grid_weapons/:id (destroy action)' do + context 'when the party is owned by a logged in user' do + let!(:grid_weapon) do + create(:grid_weapon, + party: party, + weapon: weapon, + position: 4, + uncap_level: 3) + end + + it 'destroys the grid weapon and returns a success response' do + expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: headers } + .to change(GridWeapon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'returns not found when trying to delete a non-existent grid weapon' do + delete '/api/v1/grid_weapons/00000000-0000-0000-0000-000000000000', headers: headers + expect(response).to have_http_status(:not_found) + end + end + + context 'when the party is anonymous (no user)' do + # For anonymous users, we override both the party and header edit key. + let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') } + let(:party) { create(:party, user: nil, edit_key: 'anonsecret') } + let!(:grid_weapon) do + create(:grid_weapon, + party: party, + weapon: weapon, + position: 4, + uncap_level: 3) + end + + it 'allows anonymous user to destroy grid weapon with correct edit key' do + expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: headers } + .to change(GridWeapon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'prevents destruction with incorrect edit key' do + wrong_headers = headers.merge('X-Edit-Key' => 'wrong') + delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: wrong_headers + expect(response).to have_http_status(:unauthorized) + end + + it 'prevents deletion when a logged in user attempts to delete an anonymous grid weapon' do + # When a logged in user (with an access token) tries to delete a grid weapon + # that belongs to an anonymous party, authorization should fail. + auth_headers = headers.except('X-Edit-Key') + expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: auth_headers } + .not_to change(GridWeapon, :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