Refactor GridWeaponsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests
This commit is contained in:
Justin Edmund 2025-02-10 18:14:29 -08:00
parent fed756f5ac
commit 9a54363a7f
2 changed files with 656 additions and 165 deletions

View file

@ -2,110 +2,143 @@
module Api module Api
module V1 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 class GridWeaponsController < Api::V1::ApiController
attr_reader :party, :incoming_weapon 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 :set, except: %w[create update_uncap_level] before_action :find_incoming_weapon, only: %i[create resolve]
before_action :find_party, only: :create before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
before_action :find_incoming_weapon, only: :create
before_action :authorize, only: %i[create update 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 def create
# Create the GridWeapon with the desired parameters return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
weapon = GridWeapon.new
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
if weapon.validate grid_weapon = GridWeapon.new(
save_weapon(weapon) weapon_params.merge(
party_id: @party.id,
weapon_id: @incoming_weapon.id
)
)
if grid_weapon.valid?
save_weapon(grid_weapon)
else 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
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
# weapons 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 def resolve
incoming = Weapon.find(resolve_params[:incoming]) incoming = Weapon.find_by(id: resolve_params[:incoming])
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) } conflicting_ids = resolve_params[:conflicting]
party = conflicting.first.party conflicting_weapons = GridWeapon.where(id: conflicting_ids)
# Destroy each conflicting weapon # 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 # Destroy the weapon at the desired position if it exists
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
GridWeapon.destroy(existing_weapon.id) if existing_weapon existing_weapon.destroy
end
uncap_level = 3 # Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
uncap_level = 4 if incoming.flb new_uncap = compute_default_uncap(incoming)
uncap_level = 5 if incoming.ulb grid_weapon = GridWeapon.create!(
party_id: @party.id,
weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id, weapon_id: incoming.id,
position: resolve_params[:position], uncap_level: uncap_level) position: resolve_params[:position],
uncap_level: new_uncap,
return unless weapon.save transcendence_step: 0
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
) )
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 end
private 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 if weapon.flb && !weapon.ulb && !weapon.transcendence
4 4
elsif weapon.ulb && !weapon.transcendence elsif weapon.ulb && !weapon.transcendence
@ -117,122 +150,213 @@ module Api
end end
end end
def check_weapon_compatibility ##
return if compatible_with_position?(incoming_weapon, weapon_params[:position]) # Computes the default uncap level for an incoming weapon.
#
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: 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 end
# Check if the incoming weapon is compatible with the specified position ##
def compatible_with_position?(incoming_weapon, position) # Normalizes the AX modifier fields for the weapon parameters.
false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series) #
true # Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
end #
# @return [void]
def conflict_weapon def normalize_ax_fields!
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon) params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
end
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
# 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)
end 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) def render_grid_weapon_view(grid_weapon, conflict_position)
GridWeaponBlueprint.render(grid_weapon, view: :full, GridWeaponBlueprint.render(grid_weapon,
root: :grid_weapon, view: :full,
meta: { replaced: conflict_position }) root: :grid_weapon,
meta: { replaced: conflict_position })
end 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) def save_weapon(weapon)
# Check weapon validation and delete existing grid weapon # Check weapon validation and delete existing grid weapon if one already exists at position
# if one already exists at position if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
if (grid_weapon = GridWeapon.where( existing.destroy
party_id: party.id,
position: weapon_params[:position]
).first)
GridWeapon.destroy(grid_weapon.id)
end end
# Set the party's element if the grid weapon is being set as mainhand # Set the party's element if the grid weapon is being set as mainhand
if weapon.position == -1 if weapon.position.to_i == -1
party.element = weapon.weapon.element @party.element = weapon.weapon.element
party.save! @party.save!
elsif [9, 10, 11].include?(weapon.position) elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
party.extra = true @party.extra = true
party.save! @party.save!
end end
# Render the weapon if it can be saved if weapon.save
return unless weapon.save output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
render json: output, status: :created
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon) else
render json: output, status: :created render_validation_error_response(weapon)
end
end end
##
# Handles conflicts when a new GridWeapon fails validation.
#
# Retrieves the array of conflicting grid weapons (via the models 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) def handle_conflict(weapon)
conflict_weapons = weapon.conflicts(party) 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 if conflict_weapon.nil?
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
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position]) output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
render json: output render json: output
else else
# Move the original grid weapon to the new position
# to preserve keys and other modifications
old_position = conflict_weapon.position old_position = conflict_weapon.position
conflict_weapon.position = weapon_params[:position] conflict_weapon.position = weapon_params[:position]
if conflict_weapon.save if conflict_weapon.save
output = render_grid_weapon_view(conflict_weapon, old_position) output = render_grid_weapon_view(conflict_weapon, old_position)
render json: output render json: output
else
render_validation_error_response(conflict_weapon)
end end
end end
end end
def set ##
@weapon = GridWeapon.where('id = ?', params[:id]).first # Renders the conflict view.
#
# @param conflict_weapons [Array<GridWeapon>] 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 end
def authorize ##
# Create # Finds and sets the GridWeapon based on the provided parameters.
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) # Searches for a grid weapon using various parameter keys and renders a not found response if it is absent.
#
render_unauthorized_response if unauthorized_create || unauthorized_update # @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 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 def weapon_params
params.require(:weapon).permit( params.require(:weapon).permit(
:id, :party_id, :weapon_id, :id, :party_id, :weapon_id,
@ -243,6 +367,10 @@ module Api
) )
end end
##
# Specifies and permits the resolve parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def resolve_params def resolve_params
params.require(:resolve).permit(:position, :incoming, conflicting: []) params.require(:resolve).permit(:position, :incoming, conflicting: [])
end end

View file

@ -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