Refactor GridWeaponsController
- Refactors controller - Adds YARD documentation - Adds Rspec tests
This commit is contained in:
parent
fed756f5ac
commit
9a54363a7f
2 changed files with 656 additions and 165 deletions
|
|
@ -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(
|
||||||
else
|
party_id: @party.id,
|
||||||
handle_conflict(weapon)
|
weapon_id: @incoming_weapon.id
|
||||||
end
|
)
|
||||||
end
|
|
||||||
|
|
||||||
def resolve
|
|
||||||
incoming = Weapon.find(resolve_params[:incoming])
|
|
||||||
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) }
|
|
||||||
party = conflicting.first.party
|
|
||||||
|
|
||||||
# Destroy each conflicting weapon
|
|
||||||
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) }
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return unless weapon.persisted?
|
if grid_weapon.valid?
|
||||||
|
save_weapon(grid_weapon)
|
||||||
|
else
|
||||||
|
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
|
||||||
|
|
||||||
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon)
|
##
|
||||||
|
# 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_by(id: resolve_params[:incoming])
|
||||||
|
conflicting_ids = resolve_params[:conflicting]
|
||||||
|
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
||||||
|
|
||||||
|
# Destroy each conflicting weapon
|
||||||
|
conflicting_weapons.each(&:destroy)
|
||||||
|
|
||||||
|
# Destroy the weapon at the desired position if it exists
|
||||||
|
if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||||
|
existing_weapon.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
##
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# @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
|
end
|
||||||
|
|
||||||
def conflict_weapon
|
##
|
||||||
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon)
|
# 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 })
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find a conflict weapon if one exists
|
##
|
||||||
def find_conflict_weapon(party, incoming_weapon)
|
# Saves the GridWeapon.
|
||||||
return unless incoming_weapon.limit
|
#
|
||||||
|
# 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 (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
|
||||||
|
existing.destroy
|
||||||
|
end
|
||||||
|
|
||||||
party.weapons.find do |weapon|
|
# Set the party's element if the grid weapon is being set as mainhand
|
||||||
series_match = incoming_weapon.series == weapon.weapon.series
|
if weapon.position.to_i == -1
|
||||||
weapon if series_match || opus_or_draconic?(weapon.weapon) && opus_or_draconic?(incoming_weapon)
|
@party.element = weapon.weapon.element
|
||||||
|
@party.save!
|
||||||
|
elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
|
||||||
|
@party.extra = true
|
||||||
|
@party.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_incoming_weapon
|
##
|
||||||
@incoming_weapon = Weapon.find_by(id: weapon_params[:weapon_id])
|
# 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 }
|
||||||
|
|
||||||
|
if conflict_weapon.nil?
|
||||||
|
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
|
||||||
|
render json: output
|
||||||
|
else
|
||||||
|
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
|
end
|
||||||
|
|
||||||
def find_party
|
##
|
||||||
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
|
# Renders the conflict view.
|
||||||
@party = Party.find(weapon_params[:party_id])
|
#
|
||||||
render_unauthorized_response if current_user && (party.user != current_user)
|
# @param conflict_weapons [Array<GridWeapon>] an array of conflicting grid weapons.
|
||||||
end
|
# @param incoming_weapon [Weapon] the incoming weapon.
|
||||||
|
# @param incoming_position [Integer] the desired position.
|
||||||
def opus_or_draconic?(weapon)
|
# @return [String] the rendered conflict view.
|
||||||
[2, 3].include?(weapon.series)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Render the conflict view as a string
|
|
||||||
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
||||||
ConflictBlueprint.render(nil, view: :weapons,
|
ConflictBlueprint.render(nil,
|
||||||
|
view: :weapons,
|
||||||
conflict_weapons: conflict_weapons,
|
conflict_weapons: conflict_weapons,
|
||||||
incoming_weapon: incoming_weapon,
|
incoming_weapon: incoming_weapon,
|
||||||
incoming_position: incoming_position)
|
incoming_position: incoming_position)
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_grid_weapon_view(grid_weapon, conflict_position)
|
##
|
||||||
GridWeaponBlueprint.render(grid_weapon, view: :full,
|
# Finds and sets the GridWeapon based on the provided parameters.
|
||||||
root: :grid_weapon,
|
#
|
||||||
meta: { replaced: conflict_position })
|
# 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
|
end
|
||||||
|
|
||||||
def save_weapon(weapon)
|
##
|
||||||
# Check weapon validation and delete existing grid weapon
|
# Finds and sets the incoming weapon.
|
||||||
# if one already exists at position
|
#
|
||||||
if (grid_weapon = GridWeapon.where(
|
# @return [void]
|
||||||
party_id: party.id,
|
def find_incoming_weapon
|
||||||
position: weapon_params[:position]
|
if params.dig(:weapon, :weapon_id).present?
|
||||||
).first)
|
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
|
||||||
GridWeapon.destroy(grid_weapon.id)
|
render_not_found_response('weapon') unless @incoming_weapon
|
||||||
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!
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_conflict(weapon)
|
|
||||||
conflict_weapons = weapon.conflicts(party)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
|
|
||||||
render json: output
|
|
||||||
else
|
else
|
||||||
# Move the original grid weapon to the new position
|
@incoming_weapon = nil
|
||||||
# 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
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set
|
##
|
||||||
@weapon = GridWeapon.where('id = ?', params[:id]).first
|
# 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
|
end
|
||||||
|
|
||||||
def authorize
|
##
|
||||||
# Create
|
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||||||
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)
|
# 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.
|
||||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
#
|
||||||
|
# @return [void]
|
||||||
|
def authorize_party_edit!
|
||||||
|
if @party.user.present?
|
||||||
|
authorize_user_party
|
||||||
|
else
|
||||||
|
authorize_anonymous_party
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Specify whitelisted properties that can be modified.
|
##
|
||||||
|
# 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
|
||||||
|
|
|
||||||
363
spec/requests/grid_weapons_controller_spec.rb
Normal file
363
spec/requests/grid_weapons_controller_spec.rb
Normal 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
|
||||||
Loading…
Reference in a new issue