diff --git a/.aidigestignore b/.aidigestignore new file mode 100644 index 0000000..8cfd108 --- /dev/null +++ b/.aidigestignore @@ -0,0 +1,26 @@ +.* + +app/assets +bin/ +coverage/ +download/ +export/ +log/ +postgres/ +public/ +storage/ +tmp/ +vendor/ +lib/ +sig/ +test/ + +LICENSE +logfile +config.ru +codebase.md +Rakefile + +db/migrate/* +db/data/* +db/seed/updates/* diff --git a/.gitignore b/.gitignore index d94a2c5..f26a26e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ !/log/.keep !/tmp/.keep +# Ignore simplecov directory +/coverage/* + # Ignore pidfiles, but keep the directory. /tmp/pids/* !/tmp/pids/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..0e1061a --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,2 @@ +Layout/MultilineOperationIndentation: + EnforcedStyle: aligned diff --git a/Gemfile b/Gemfile index e49d1ec..ac583dd 100644 --- a/Gemfile +++ b/Gemfile @@ -84,8 +84,7 @@ end group :development, :test do gem 'amazing_print' gem 'dotenv-rails' - gem 'factory_bot_rails' - gem 'faker' + gem 'prosopite' gem 'pry' gem 'rspec_junit_formatter' gem 'rspec-rails' @@ -94,7 +93,6 @@ end group :development do gem 'listen' gem 'pg_query' - gem 'prosopite' gem 'solargraph' gem 'spring' gem 'spring-commands-rspec' @@ -109,6 +107,9 @@ group :test do gem 'api_matchers' gem 'byebug' gem 'database_cleaner' + gem 'factory_bot_rails' + gem 'faker' + gem 'rspec' gem 'shoulda-matchers' gem 'simplecov', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 5acdef0..5c8975a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -485,6 +485,7 @@ DEPENDENCIES rails redis responders + rspec rspec-rails rspec_junit_formatter rubocop diff --git a/app/blueprints/api/v1/party_blueprint.rb b/app/blueprints/api/v1/party_blueprint.rb index 845c231..0dcbd2e 100644 --- a/app/blueprints/api/v1/party_blueprint.rb +++ b/app/blueprints/api/v1/party_blueprint.rb @@ -28,7 +28,7 @@ module Api # Metadata associations field :favorited do |party, options| - party.is_favorited(options[:current_user]) + party.favorited?(options[:current_user]) end # For collection views @@ -41,7 +41,7 @@ module Api view :full do # Primary object associations include_view :nested_objects # Characters, Weapons, Summons - include_view :nested_metadata # Remixes, Source party + include_view :remix_metadata # Remixes, Source party include_view :job_metadata # Accessory, Skills, Guidebooks end @@ -85,11 +85,15 @@ module Api end end - view :nested_metadata do + view :source_party do association :source_party, blueprint: PartyBlueprint, - view: :minimal, + view: :preview, if: ->(_field_name, party, _options) { party.source_party_id.present? } + end + + view :remix_metadata do + include_view :source_party # Re-added remixes association association :remixes, @@ -127,6 +131,11 @@ module Api fields :edit_key end + view :remixed do + include_view :created + include_view :source_party + end + # Destroyed view view :destroyed do fields :name, :description, :created_at, :updated_at diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index 887eeaa..c2a6628 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -86,7 +86,7 @@ module Api end def render_unprocessable_entity_response(exception) - render json: ErrorBlueprint.render_as_json(nil, exception: exception), + render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash), status: :unprocessable_entity end diff --git a/app/controllers/api/v1/grid_characters_controller.rb b/app/controllers/api/v1/grid_characters_controller.rb index 6741719..a37fa56 100644 --- a/app/controllers/api/v1/grid_characters_controller.rb +++ b/app/controllers/api/v1/grid_characters_controller.rb @@ -2,73 +2,115 @@ module Api module V1 + ## + # Controller handling API requests related to grid characters within a party. + # + # This controller provides endpoints for creating, updating, resolving conflicts, + # updating uncap levels, and deleting grid characters. It follows the structure of + # GridSummonsController and GridWeaponsController by using the new authorization method + # `authorize_party_edit!` and deprecating legacy methods such as `set` in favor of + # `find_party`, `find_grid_character`, and `find_incoming_character`. + # + # @see Api::V1::ApiController for shared API behavior. class GridCharactersController < Api::V1::ApiController - attr_reader :party, :incoming_character, :current_characters - - before_action :find_party, only: :create - before_action :set, only: %i[update destroy] - before_action :authorize, only: %i[create update destroy] + before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve] + before_action :find_party, only: %i[create resolve update update_uncap_level destroy] before_action :find_incoming_character, only: :create - before_action :find_current_characters, only: :create + before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy] + ## + # Creates a new grid character. + # + # If a conflicting grid character is found (i.e. one with the same character_id already exists + # in the party), a conflict view is rendered so the user can decide on removal. Otherwise, + # any grid character occupying the desired position is removed and a new one is created. + # + # @return [void] def create - if !conflict_characters.nil? && conflict_characters.length.positive? - # Render a template with the conflicting and incoming characters, - # as well as the selected position, so the user can be presented with - # a decision. + processed_params = transform_character_params(character_params) - # Up to 3 characters can be removed at the same time - conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position]) - render json: conflict_view + if conflict_characters.present? + render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position]) else - # Destroy the grid character in the position if it is already filled - if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists? - character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0] - character.destroy + # Remove any existing grid character occupying the specified position. + if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position])) + existing.destroy end - # Then, create a new grid character - character = GridCharacter.create!(character_params.merge(party_id: party.id, - character_id: incoming_character.id)) + # Build the new grid character + grid_character = build_new_grid_character(processed_params) - if character.save! - grid_character_view = render_grid_character_view(character) - render json: grid_character_view, status: :created + if grid_character.save + render json: GridCharacterBlueprint.render(grid_character, + root: :grid_character, + view: :nested), status: :created + else + render_validation_error_response(grid_character) end end end + ## + # Updates an existing grid character. + # + # Assigns new rings and awakening data to their respective virtual attributes and updates other + # permitted attributes. On success, the updated grid character view is rendered. + # + # @return [void] def update - permitted = character_params.to_h.deep_symbolize_keys - puts "Permitted:" - ap permitted + processed_params = transform_character_params(character_params) + assign_raw_attributes(@grid_character) + assign_transformed_attributes(@grid_character, processed_params) - # For the new nested structure, assign them to the virtual attributes: - @character.new_rings = permitted[:rings] if permitted[:rings].present? - @character.new_awakening = permitted[:awakening] if permitted[:awakening].present? - - # For the rest of the attributes, you can assign them normally. - @character.assign_attributes(permitted.except(:rings, :awakening)) - - if @character.save - render json: GridCharacterBlueprint.render(@character, view: :nested) + if @grid_character.save + render json: GridCharacterBlueprint.render(@grid_character, + root: :grid_character, + view: :nested) else - render_validation_error_response(@character) + render_validation_error_response(@grid_character) end end + ## + # Updates the uncap level and transcendence step of a grid character. + # + # The grid character's uncap level and transcendence step are updated based on the provided parameters. + # This action requires that the current user is authorized to modify the party. + # + # @return [void] + def update_uncap_level + @grid_character.uncap_level = character_params[:uncap_level] + @grid_character.transcendence_step = character_params[:transcendence_step] + + if @grid_character.save + render json: GridCharacterBlueprint.render(@grid_character, + root: :grid_character, + view: :nested) + else + render_validation_error_response(@grid_character) + end + end + + ## + # Resolves conflicts for grid characters. + # + # This action destroys any conflicting grid characters as well as any grid character occupying + # the target position, then creates a new grid character using a computed default uncap level. + # The default uncap level is determined by the incoming character's attributes. + # + # @return [void] def resolve - incoming = Character.find(resolve_params[:incoming]) - conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) } - party = conflicting.first.party + incoming = Character.find_by(id: resolve_params[:incoming]) + render_not_found_response('character') and return unless incoming - # Destroy each conflicting character - conflicting.each { |character| GridCharacter.destroy(character.id) } + conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact + conflicting.each(&:destroy) - # Destroy the character at the desired position if it exists - existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first - GridCharacter.destroy(existing_character.id) if existing_character + if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position])) + existing.destroy + end + # Compute the default uncap level based on the incoming character's flags. if incoming.special uncap_level = 3 uncap_level = 5 if incoming.ulb @@ -79,33 +121,146 @@ module Api uncap_level = 5 if incoming.flb end - character = GridCharacter.create!(party_id: party.id, character_id: incoming.id, - position: resolve_params[:position], uncap_level: uncap_level) - render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save! + grid_character = GridCharacter.create!( + party_id: @party.id, + character_id: incoming.id, + position: resolve_params[:position], + uncap_level: uncap_level + ) + render json: GridCharacterBlueprint.render(grid_character, + root: :grid_character, + view: :nested), status: :created end - def update_uncap_level - character = GridCharacter.find(character_params[:id]) - - render_unauthorized_response if current_user && (character.party.user != current_user) - - character.uncap_level = character_params[:uncap_level] - character.transcendence_step = character_params[:transcendence_step] - return unless character.save! - - render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character) - end - - # TODO: Implement removing characters + ## + # Destroys a grid character. + # + # If the current user is not the owner of the party, an unauthorized response is rendered. + # On successful destruction, the destroyed grid character view is rendered. + # + # @return [void] def destroy - render_unauthorized_response if @character.party.user != current_user - return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy + grid_character = GridCharacter.find_by('id = ?', params[:id]) + + return render_not_found_response('grid_character') if grid_character.nil? + + render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy end private + ## + # Builds a new grid character using the transformed parameters. + # + # @param processed_params [Hash] the transformed parameters. + # @return [GridCharacter] the newly built grid character. + def build_new_grid_character(processed_params) + grid_character = GridCharacter.new( + character_params.except(:rings, :awakening).merge( + party_id: @party.id, + character_id: @incoming_character.id + ) + ) + assign_transformed_attributes(grid_character, processed_params) + assign_raw_attributes(grid_character) + grid_character + end + + ## + # Assigns raw attributes from the original parameters to the grid character. + # + # These attributes (like new_rings and new_awakening) are used by model callbacks. + # + # @param grid_character [GridCharacter] the grid character instance. + # @return [void] + def assign_raw_attributes(grid_character) + grid_character.new_rings = character_params[:rings] if character_params[:rings].present? + grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present? + grid_character.assign_attributes(character_params.except(:rings, :awakening)) + end + + ## + # Assigns transformed attributes (such as uncap_level, transcendence_step, etc.) to the grid character. + # + # @param grid_character [GridCharacter] the grid character instance. + # @param processed_params [Hash] the transformed parameters. + # @return [void] + def assign_transformed_attributes(grid_character, processed_params) + grid_character.uncap_level = processed_params[:uncap_level] if processed_params[:uncap_level] + grid_character.transcendence_step = processed_params[:transcendence_step] if processed_params[:transcendence_step] + grid_character.perpetuity = processed_params[:perpetuity] if processed_params.key?(:perpetuity) + grid_character.earring = processed_params[:earring] if processed_params[:earring] + + return unless processed_params[:awakening_id] + + grid_character.awakening_id = processed_params[:awakening_id] + grid_character.awakening_level = processed_params[:awakening_level] + end + + ## + # Transforms the incoming character parameters to the required format. + # + # The frontend sends parameters in a raw format that need to be processed (e.g., converting string + # values to integers, handling nested attributes for rings and awakening). This method extracts and + # converts only the keys that were provided. + # + # @param raw_params [ActionController::Parameters] the raw permitted parameters. + # @return [Hash] the transformed parameters. + def transform_character_params(raw_params) + # Convert to a symbolized hash for convenience. + raw = raw_params.to_h.deep_symbolize_keys + + # Only update keys that were provided. + transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) + transformed[:uncap_level] = raw[:uncap_level] if raw[:uncap_level].present? + transformed[:transcendence_step] = raw[:transcendence_step] if raw[:transcendence_step].present? + + # Process rings if provided. + transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? + + # Process earring if provided. + transformed[:earring] = raw[:earring] if raw[:earring].present? + + # Process awakening if provided. + if raw[:awakening].present? + transformed[:awakening_id] = raw[:awakening][:id] + # Default to 1 if level is missing (to satisfy validations) + transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level] : 1 + end + + transformed + end + + ## + # Transforms the rings data to ensure exactly four rings are present. + # + # Pads the array with a default ring hash if necessary. + # + # @param rings [Array, Hash] the rings data from the frontend. + # @return [Hash] a hash with keys :ring1, :ring2, :ring3, :ring4. + def transform_rings(rings) + default_ring = { modifier: nil, strength: nil } + # Ensure rings is an array of hashes. + rings_array = Array(rings).map(&:to_h) + # Pad the array to exactly four rings if needed. + rings_array.fill(default_ring, rings_array.size...4) + { + ring1: rings_array[0], + ring2: rings_array[1], + ring3: rings_array[2], + ring4: rings_array[3] + } + end + + ## + # Returns any grid characters in the party that conflict with the incoming character. + # + # Conflict is defined as any grid character already in the party with the same character_id as the + # incoming character. This method is used to prompt the user for conflict resolution. + # + # @return [Array] def conflict_characters - @conflict_characters ||= find_conflict_characters(incoming_character) + @party.characters.where(character_id: @incoming_character.id).to_a end def find_conflict_characters(incoming_character) @@ -127,67 +282,102 @@ module Api end.flatten end - def set - @character = GridCharacter.includes(:awakening).find(params[:id]) - end - - def find_incoming_character - @incoming_character = Character.find(character_params[:character_id]) - end - + ## + # Finds and sets the party based on parameters. + # + # Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party + # associated with the current grid character. Renders a not found response if the party is missing. + # + # @return [void] def find_party - @party = Party.find(character_params[:party_id]) - render_unauthorized_response if current_user && (party.user != current_user) + @party = Party.find_by(id: params.dig(:character, :party_id)) || + Party.find_by(id: params[:party_id]) || + @grid_character&.party + render_not_found_response('party') unless @party end - def authorize - # Create - unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key) - unauthorized_update = @character && @character.party && (@character.party.user != current_user || @character.party.edit_key != edit_key) - - render_unauthorized_response if unauthorized_create || unauthorized_update + ## + # Finds and sets the grid character based on the provided parameters. + # + # Searches for a grid character by its ID and renders a not found response if it is absent. + # + # @return [void] + def find_grid_character + grid_character_id = params[:id] || params.dig(:character, :id) || params.dig(:resolve, :conflicting) + @grid_character = GridCharacter.includes(:awakening).find_by(id: grid_character_id) + render_not_found_response('grid_character') unless @grid_character end - def transform_character_params(raw_params) - # Convert to a symbolized hash for convenience. - raw = raw_params.deep_symbolize_keys + ## + # Finds and sets the incoming character based on the provided parameters. + # + # Searches for a character using the :character_id parameter and renders a not found response if it is absent. + # + # @return [void] + def find_incoming_character + @incoming_character = Character.find_by(id: character_params[:character_id]) + render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character + end - # Only update keys that were provided. - transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) - transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present? - transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present? - - # Process rings if provided. - transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? - - # Process earring if provided. - transformed[:earring] = raw[:earring] if raw[:earring].present? - - # Process awakening if provided. - if raw[:awakening].present? - transformed[:awakening_id] = raw[:awakening][:id] - # Default to 1 if level is missing (to satisfy validations) - transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1 + ## + # Authorizes the current action by ensuring that the current user or provided edit key + # matches the party's owner. + # + # For parties associated with a user, it verifies that the current user is the owner. + # For anonymous parties, it compares the provided edit key with the party's edit key. + # + # @return [void] + def authorize_party_edit! + if @party.user.present? + authorize_user_party + else + authorize_anonymous_party end - - transformed end - def transform_rings(rings) - default_ring = { modifier: nil, strength: nil } - # Ensure rings is an array of hashes. - rings_array = Array(rings).map(&:to_h) - # Pad the array to exactly four rings if needed. - rings_array.fill(default_ring, rings_array.size...4) - { - ring1: rings_array[0], - ring2: rings_array[1], - ring3: rings_array[2], - ring4: rings_array[3] - } + ## + # Authorizes an action for a party that belongs to a user. + # + # Renders an unauthorized response unless the current user is present and matches the party's user. + # + # @return [void] + def authorize_user_party + return if current_user.present? && @party.user == current_user + + render_unauthorized_response end - # Specify whitelisted properties that can be modified. + ## + # Authorizes an action for an anonymous party using an edit key. + # + # Compares the provided edit key with the party's edit key and renders an unauthorized response + # if they do not match. + # + # @return [void] + def authorize_anonymous_party + provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8') + party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8') + return if valid_edit_key?(provided_edit_key, party_edit_key) + + render_unauthorized_response + end + + ## + # Validates that the provided edit key matches the party's edit key. + # + # @param provided_edit_key [String] the edit key provided in the request. + # @param party_edit_key [String] the edit key associated with the party. + # @return [Boolean] true if the keys match; false otherwise. + def valid_edit_key?(provided_edit_key, party_edit_key) + provided_edit_key.present? && + provided_edit_key.bytesize == party_edit_key.bytesize && + ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) + end + + ## + # Specifies and permits the allowed character parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def character_params params.require(:character).permit( :id, @@ -203,21 +393,13 @@ module Api ) end + ## + # Specifies and permits the allowed resolve parameters. + # + # @return [ActionController::Parameters] the permitted parameters. def resolve_params params.require(:resolve).permit(:position, :incoming, conflicting: []) end - - def render_conflict_view(conflict_characters, incoming_character, incoming_position) - ConflictBlueprint.render(nil, - view: :characters, - conflict_characters: conflict_characters, - incoming_character: incoming_character, - incoming_position: incoming_position) - end - - def render_grid_character_view(grid_character) - GridCharacterBlueprint.render(grid_character, view: :nested) - end end end end diff --git a/app/controllers/api/v1/grid_summons_controller.rb b/app/controllers/api/v1/grid_summons_controller.rb index 4fd6e6e..47ec1b0 100644 --- a/app/controllers/api/v1/grid_summons_controller.rb +++ b/app/controllers/api/v1/grid_summons_controller.rb @@ -2,82 +2,142 @@ module Api module V1 + ## + # Controller handling API requests related to grid summons within a party. + # + # This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid summons. + # It ensures that the correct party and summons are found and that the current user (or edit key) is authorized. + # + # @see Api::V1::ApiController for shared API behavior. class GridSummonsController < Api::V1::ApiController attr_reader :party, :incoming_summon - before_action :set, only: %w[update update_uncap_level update_quick_summon] - before_action :find_party, only: :create + before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy] + before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy] before_action :find_incoming_summon, only: :create - before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy] + before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy] + ## + # Creates a new grid summon. + # + # This method builds a new grid summon using the permitted parameters merged + # with the party and summon IDs. It ensures that the `uncap_level` is set to the + # maximum allowed level if not provided. Depending on validation, it will either save + # the summon, handle conflict resolution, or render a validation error response. + # + # @return [void] def create - # Create the GridSummon with the desired parameters - summon = GridSummon.new - summon.attributes = summon_params.merge(party_id: party.id, summon_id: incoming_summon.id) - summon.uncap_level = max_uncap_level(summon) if summon.uncap_level.nil? + # Build a new grid summon using permitted parameters merged with party and summon IDs. + # Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper + # if it hasn't already been provided. + grid_summon = build_grid_summon.tap do |gs| + gs.uncap_level ||= max_uncap_level(gs) + end - if summon.validate - save_summon(summon) + # If the grid summon is valid (i.e. it passes all validations), then save it normally. + if grid_summon.valid? + save_summon(grid_summon) + # If it is invalid due to a conflict error, handle the conflict resolution flow. + elsif conflict_error?(grid_summon) + handle_conflict(grid_summon) + # If there's some other kind of validation error, render the validation error response back to the client. else - handle_conflict(summon) + render_validation_error_response(grid_summon) end end + ## + # Updates an existing grid summon. + # + # Updates the grid summon attributes using permitted parameters. If the update is successful, + # it renders the updated grid summon view; otherwise, it renders a validation error response. + # + # @return [void] def update - @summon.attributes = summon_params + @grid_summon.attributes = summon_params - return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save + return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save - render_validation_error_response(@character) + render_validation_error_response(@grid_summon) end + ## + # Updates the uncap level and transcendence step of a grid summon. + # + # This action recalculates the maximum allowed uncap level based on the summon attributes + # and applies business logic to adjust the uncap level and transcendence step accordingly. + # On success, it renders the updated grid summon view; otherwise, it renders a validation error response. + # + # @return [void] def update_uncap_level - summon = @summon.summon - max_uncap_level = max_uncap_level(summon) + summon = @grid_summon.summon + max_level = max_uncap_level(summon) - greater_than_max_uncap = summon_params[:uncap_level].to_i > max_uncap_level - can_be_transcended = summon.transcendence && summon_params[:transcendence_step] && summon_params[:transcendence_step]&.to_i&.positive? + greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level + can_be_transcended = summon.transcendence && + summon_params[:transcendence_step].present? && + summon_params[:transcendence_step].to_i.positive? - uncap_level = if greater_than_max_uncap || can_be_transcended - max_uncap_level - else - summon_params[:uncap_level] - end + new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level] + new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0 - transcendence_step = if summon.transcendence && summon_params[:transcendence_step] - summon_params[:transcendence_step] - else - 0 - end - - @summon.update!( - uncap_level: uncap_level, - transcendence_step: transcendence_step - ) - - return unless @summon.persisted? - - render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) + if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step) + render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) + else + render_validation_error_response(@grid_summon) + end end + ## + # Updates the quick summon status for a grid summon. + # + # If the grid summon is in positions 4, 5, or 6, no update is performed. + # Otherwise, it disables quick summon for all other summons in the party, + # updates the current summon, and renders the updated list of summons. + # + # @return [void] def update_quick_summon - return if [4, 5, 6].include?(@summon.position) + return if [4, 5, 6].include?(@grid_summon.position) - quick_summons = @summon.party.summons.select(&:quick_summon) + quick_summons = @grid_summon.party.summons.select(&:quick_summon) quick_summons.each do |summon| summon.update!(quick_summon: false) end - @summon.update!(quick_summon: summon_params[:quick_summon]) - return unless @summon.persisted? + @grid_summon.update!(quick_summon: summon_params[:quick_summon]) + return unless @grid_summon.persisted? - quick_summons -= [@summon] - summons = [@summon] + quick_summons + quick_summons -= [@grid_summon] + summons = [@grid_summon] + quick_summons render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons) end + # + # Destroys a grid summon. + # + # Finds the grid summon by ID. If not found, renders a not-found response. + # If the current user is not authorized to perform the deletion, renders an unauthorized response. + # On successful destruction, renders the destroyed grid summon view. + # + # @return [void] + def destroy + grid_summon = GridSummon.find_by('id = ?', params[:id]) + + return render_not_found_response('grid_summon') if grid_summon.nil? + + render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy + end + + ## + # Saves the provided grid summon. + # + # If an existing grid summon is found at the specified position for the party, it is replaced. + # On successful save, renders the grid summon view with a created status. + # + # @param summon [GridSummon] The grid summon instance to be saved. + # @return [void] def save_summon(summon) if (grid_summon = GridSummon.where( party_id: party.id, @@ -92,6 +152,15 @@ module Api render json: output, status: :created end + ## + # Handles conflict resolution for a grid summon. + # + # If a conflict is detected and the conflicting summon matches the incoming summon, + # the method updates the conflicting summon’s position with the new position. + # On a successful update, renders the updated grid summon view. + # + # @param summon [GridSummon] The grid summon instance that encountered a conflict. + # @return [void] def handle_conflict(summon) conflict_summon = summon.conflicts(party) return unless conflict_summon.summon.id == incoming_summon.id @@ -105,14 +174,96 @@ module Api render json: output end - def destroy - summon = GridSummon.find_by('id = ?', params[:id]) - render_unauthorized_response if summon.party.user != current_user - return render json: GridSummonBlueprint.render(summon, view: :destroyed) if summon.destroy - end - private + ## + # Finds the party based on the provided party_id parameter. + # + # Sets the @party instance variable and renders an unauthorized response if the current + # user is not the owner of the party. + # + # @return [void] + + ## + # 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(:summon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_summon&.party + render_not_found_response('party') unless @party + end + + ## + # Finds and sets the GridSummon based on the provided parameters. + # + # Searches for a grid summon using various parameter keys and renders a not found response if it is absent. + # + # @return [void] + def find_grid_summon + grid_summon_id = params[:id] || params.dig(:summon, :id) || params.dig(:resolve, :conflicting) + @grid_summon = GridSummon.find_by(id: grid_summon_id) + render_not_found_response('grid_summon') unless @grid_summon + end + + ## + # Finds the incoming summon based on the provided parameters. + # + # Sets the @incoming_summon instance variable. + # + # @return [void] + def find_incoming_summon + @incoming_summon = Summon.find_by(id: summon_params[:summon_id]) + end + + ## + # Builds a new GridSummon instance using permitted parameters. + # + # Merges the party id and the incoming summon id into the parameters. + # + # @return [GridSummon] A new grid summon instance. + def build_grid_summon + GridSummon.new(summon_params.merge(party_id: party.id, summon_id: incoming_summon.id)) + end + + ## + # Checks whether the grid summon error is solely due to a conflict. + # + # Verifies if the errors on the :series attribute include the specific conflict message + # and confirms that a conflict exists for the current party. + # + # @param grid_summon [GridSummon] The grid summon instance to check. + # @return [Boolean] True if the error is due solely to a conflict, false otherwise. + def conflict_error?(grid_summon) + grid_summon.errors[:series].include?('must not conflict with existing summons') && + grid_summon.conflicts(party).present? + end + + ## + # Renders the grid summon view with additional metadata. + # + # @param grid_summon [GridSummon] The grid summon instance to render. + # @param conflict_position [Integer, nil] The position of a conflicting summon, if applicable. + # @return [String] The rendered grid summon view as JSON. + def render_grid_summon_view(grid_summon, conflict_position = nil) + GridSummonBlueprint.render(grid_summon, + view: :nested, + root: :grid_summon, + meta: { replaced: conflict_position }) + end + + ## + # Determines the maximum uncap level for a given summon. + # + # The maximum uncap level is determined based on the attributes of the summon: + # - Returns 4 if the summon has FLB but not ULB and is not transcended. + # - Returns 5 if the summon has ULB and is not transcended. + # - Returns 6 if the summon has transcendence. + # - Otherwise, returns 3. + # + # @param summon [Summon] The summon for which to determine the maximum uncap level. + # @return [Integer] The maximum uncap level. def max_uncap_level(summon) if summon.flb && !summon.ulb && !summon.transcendence 4 @@ -125,35 +276,65 @@ module Api end end - def find_incoming_summon - @incoming_summon = Summon.find_by(id: summon_params[:summon_id]) + ## + # 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 - def find_party - # BUG: I can create grid weapons even when I'm not logged in on an authenticated party - @party = Party.find(summon_params[:party_id]) - render_unauthorized_response if current_user && (party.user != current_user) + ## + # Authorizes an action for a party that belongs to a user. + # + # Renders an unauthorized response unless the current user is present and + # matches the party's user. + # + # @return [void] + def authorize_user_party + return if current_user.present? && @party.user == current_user + + render_unauthorized_response end - def render_grid_summon_view(grid_summon, conflict_position = nil) - GridSummonBlueprint.render(grid_summon, view: :nested, - root: :grid_summon, - meta: { replaced: conflict_position }) + ## + # 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) + + render_unauthorized_response end - def authorize - # Create - unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key) - unauthorized_update = @summon && @summon.party && (@summon.party.user != current_user || @summon.party.edit_key != edit_key) - - render_unauthorized_response if unauthorized_create || unauthorized_update + ## + # 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 - def set - @summon = GridSummon.find_by('id = ?', summon_params[:id]) - end - - # Specify whitelisted properties that can be modified. + ## + # Defines and permits the whitelisted parameters for a grid summon. + # + # @return [ActionController::Parameters] The permitted parameters. def summon_params params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend, :quick_summon, :uncap_level, :transcendence_step) 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/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 4221116..b55f096 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -5,12 +5,11 @@ module Api # Controller for managing party-related operations in the API # @api public class PartiesController < Api::V1::ApiController - before_action :set_from_slug, - except: %w[create destroy update index favorites] - before_action :set, only: %w[update destroy] - before_action :authorize, only: %w[update destroy] + include PartyAuthorizationConcern + include PartyQueryingConcern + include PartyPreviewConcern - # == Constants + # Constants used for filtering validations. # Maximum number of characters allowed in a party MAX_CHARACTERS = 5 @@ -33,728 +32,163 @@ module Api # Default maximum clear time in seconds DEFAULT_MAX_CLEAR_TIME = 5400 - # == Primary CRUD Actions + before_action :set_from_slug, except: %w[create destroy update index favorites] + before_action :set, only: %w[update destroy] + before_action :authorize_party!, only: %w[update destroy] + + # Primary CRUD Actions # Creates a new party with optional user association # @return [void] + # Creates a new party. def create - # Build the party with the provided parameters and assign the user party = Party.new(party_params) party.user = current_user if current_user - - # If a raid_id is given, look it up and assign the extra flag from its group. if party_params && party_params[:raid_id].present? if (raid = Raid.find_by(id: party_params[:raid_id])) party.extra = raid.group.extra end end - - # Save and render the party, triggering preview generation if the party is ready if party.save party.schedule_preview_generation if party.ready_for_preview? - render json: PartyBlueprint.render(party, view: :created, root: :party), - status: :created + render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created else render_validation_error_response(party) end end - # Shows a specific party if the user has permission to view it - # @return [void] + # Shows a specific party. def show - # If a party is private, check that the user is the owner or an admin - if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode) - return render_unauthorized_response + return render_unauthorized_response if @party.private? && (!current_user || not_owner?) + + if @party + render json: PartyBlueprint.render(@party, view: :full, root: :party) + else + render_not_found_response('project') end - - return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party - - render_not_found_response('project') end - # Updates an existing party's attributes - # @return [void] + # Updates an existing party. def update @party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id) - if party_params && party_params[:raid_id] - raid = Raid.find_by(id: party_params[:raid_id]) - @party.extra = raid.group.extra + if (raid = Raid.find_by(id: party_params[:raid_id])) + @party.extra = raid.group.extra + end + end + if @party.save + render json: PartyBlueprint.render(@party, view: :full, root: :party) + else + render_validation_error_response(@party) end - - # TODO: Validate accessory with job - - return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party.save - - render_validation_error_response(@party) end - # Deletes a party if the user has permission - # @return [void] + # Deletes a party. def destroy render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy end - # == Extended Party Actions + # Extended Party Actions - # Creates a copy of an existing party with attribution - # @return [void] + # Creates a remixed copy of an existing party. def remix new_party = @party.amoeba_dup - new_party.attributes = { - user: current_user, - name: remixed_name(@party.name), - source_party: @party, - remix: true - } - - new_party.local_id = party_params[:local_id] unless party_params.nil? - + new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true } + new_party.local_id = party_params[:local_id] if party_params if new_party.save - # Remixed parties should have content, so generate preview new_party.schedule_preview_generation - render json: PartyBlueprint.render(new_party, view: :created, root: :party), - status: :created + render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created else render_validation_error_response(new_party) end end - # Lists parties based on various filter criteria - # @return [void] + # Lists parties based on query parameters. def index query = build_parties_query @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) render_paginated_parties(@parties) end - # Lists parties favorited by the current user - # @return [void] + # Lists parties favorited by the current user. def favorites raise Api::V1::UnauthorizedError unless current_user - + ap "Total favorites in DB: #{Favorite.count}" query = build_parties_query(favorites: true) @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) - # Mark each party as favorited (if needed) - @parties.each { |party| party.favorited = true } render_paginated_parties(@parties) end - # == Preview Management + # Preview Management # Serves the party's preview image # @return [void] + # Serves the party's preview image. def preview - coordinator = PreviewService::Coordinator.new(@party) - - if coordinator.generation_in_progress? - response.headers['Retry-After'] = '2' - default_path = Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png") - send_file default_path, - type: 'image/png', - disposition: 'inline' - return - end - - # Try to get the preview or send default - begin - if Rails.env.production? - # Stream S3 content instead of redirecting - s3_object = coordinator.get_s3_object - send_data s3_object.body.read, - filename: "#{@party.shortcode}.png", - type: 'image/png', - disposition: 'inline' - else - # In development, serve from local filesystem - send_file coordinator.local_preview_path, - type: 'image/png', - disposition: 'inline' - end - rescue Aws::S3::Errors::NoSuchKey - # Schedule generation if needed - coordinator.schedule_generation unless coordinator.generation_in_progress? - - # Return default preview while generating - send_file Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png"), - type: 'image/png', - disposition: 'inline' - end + party_preview(@party) end - # Returns the current status of a party's preview - # @return [void] + # Returns the current preview status of a party. def preview_status party = Party.find_by!(shortcode: params[:id]) - render json: { - state: party.preview_state, - generated_at: party.preview_generated_at, - ready_for_preview: party.ready_for_preview? - } + render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? } end - # Forces regeneration of a party's preview image - # @return [void] + # Forces regeneration of the party preview. def regenerate_preview party = Party.find_by!(shortcode: params[:id]) - - # Ensure only party owner can force regeneration - unless current_user && party.user_id == current_user.id - return render_unauthorized_response - end + return render_unauthorized_response unless current_user && party.user_id == current_user.id preview_service = PreviewService::Coordinator.new(party) if preview_service.force_regenerate render json: { status: 'Preview regeneration started' } else - render json: { error: 'Preview regeneration failed' }, - status: :unprocessable_entity + render json: { error: 'Preview regeneration failed' }, status: :unprocessable_entity end end private - # Builds the base query for parties, optionally including favorites-specific conditions. - def build_parties_query(favorites: false) - query = Party.includes( - { raid: :group }, - :job, - :user, - :skill0, - :skill1, - :skill2, - :skill3, - :guidebook1, - :guidebook2, - :guidebook3, - { characters: :character }, - { weapons: :weapon }, - { summons: :summon } - ) - # Add favorites join and condition if favorites is true. - if favorites - query = query.joins(:favorites) - .where(favorites: { user_id: current_user.id }) - .distinct - query = query.order(created_at: :desc) - else - query = query.order(visibility: :asc, created_at: :desc) - end - - query = apply_filters(query) - query = apply_privacy_settings(query) - query = apply_includes(query, params[:includes]) if params[:includes].present? - query = apply_excludes(query, params[:excludes]) if params[:excludes].present? - query - end - - # Renders the paginated parties with blueprint and meta data. - def render_paginated_parties(parties) - render json: PartyBlueprint.render( - parties, - view: :preview, - root: :results, - meta: { - count: parties.total_entries, - total_pages: parties.total_pages, - per_page: COLLECTION_PER_PAGE - }, - current_user: current_user - ) - end - - # == Authorization Helpers - - # Checks if the current user is authorized to modify the party - # @return [void] - def authorize - return unless not_owner && !admin_mode - - render_unauthorized_response - end - - # Determines if the current user is not the owner of the party - # @return [Boolean] - def not_owner - if @party.user - # party has a user and current_user does not match - return true if current_user != @party.user - - # party has a user, there's no current_user, but edit_key is provided - return true if current_user.nil? && edit_key - else - # party has no user, there's no current_user and there's no edit_key provided - return true if current_user.nil? && edit_key.nil? - - # party has no user, there's no current_user, and the party's edit_key doesn't match the provided edit_key - return true if current_user.nil? && @party.edit_key != edit_key - end - - false - end - - # == Preview Generation - - # Schedules a background job to generate the party preview - # @return [void] - def schedule_preview_generation - GeneratePartyPreviewJob.perform_later(id) - end - - # == Query Building Helpers - - def apply_filters(query) - conditions = build_filters - - # Use the compound indexes effectively - query = query.where(conditions) - .where(name_quality) if params[:name_quality].present? - - # Use the counters index - query = query.where( - weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS, - characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS, - summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS - ) - - query - end - - def apply_privacy_settings(query) - return query if admin_mode - - if params[:favorites].present? - query.where('visibility < 3') - else - query.where(visibility: 1) - end - end - - # Builds filter conditions from request parameters - # @return [Hash] conditions for the query - def build_filters - { - element: params[:element].present? ? params[:element].to_i : nil, - raid_id: params[:raid], - created_at: build_date_range, - full_auto: build_option(params[:full_auto]), - auto_guard: build_option(params[:auto_guard]), - charge_attack: build_option(params[:charge_attack]), - characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS, - summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS, - weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS - }.compact - end - - def build_date_range - return nil unless params[:recency].present? - - start_time = DateTime.current - params[:recency].to_i.seconds - start_time.beginning_of_day..DateTime.current - end - - # Paginates the given query of parties and marks favorites for the current user - # - # @param query [ActiveRecord::Relation] The base query containing parties - # @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`) - # @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`) - # @return [ActiveRecord::Relation] The paginated and processed list of parties - # - # This method orders parties by creation date in descending order, applies pagination, - # and marks each party as favorited if the current user has favorited it. - def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE) - query.order(created_at: :desc) - .paginate(page: page || params[:page], per_page: per_page) - .tap do |parties| - if current_user - parties.each { |party| party.favorited = party.is_favorited(current_user) } - end - end - end - - # == Parameter Processing Helpers - - # Converts start time parameter for filtering - # @param recency [String, nil] time period in seconds - # @return [DateTime, nil] calculated start time - def build_start_time(recency) - return unless recency.present? - - (DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day - end - - # Builds count parameter with default fallback - # @param value [String, nil] count value - # @param default [Integer] default value - # @return [Integer] processed count - def build_count(value, default) - value.blank? ? default : value.to_i - end - - # Processes maximum clear time parameter - # @param value [String, nil] clear time value in seconds - # @return [Integer] processed maximum clear time - def build_max_clear_time(value) - value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i - end - - # Processes element parameter - # @param element [String, nil] element identifier - # @return [Integer, nil] processed element value - def build_element(element) - element.to_i unless element.blank? - end - - # Processes boolean option parameters - # @param value [String, nil] option value - # @return [Integer, nil] processed option value - def build_option(value) - value.to_i unless value.blank? || value.to_i == -1 - end - - # == Query Building Helpers - - # Constructs the main query for party filtering - # @param conditions [Hash] filter conditions - # @param favorites [Boolean] whether to include favorites - # @return [ActiveRecord::Relation] constructed query - def build_query(conditions, favorites: false) - query = Party.distinct - # joins vs includes? -> reduces n+1s - .preload( - weapons: { object: %i[name_en name_jp granblue_id element] }, - summons: { object: %i[name_en name_jp granblue_id element] }, - characters: { object: %i[name_en name_jp granblue_id element] } - ) - .group('parties.id') - .where(conditions) - .where(privacy(favorites: favorites)) - .where(name_quality) - .where(user_quality) - .where(original) - - query = query.includes(:favorites) if favorites - - query - end - - # Applies the include conditions to query - # @param query [ActiveRecord::Relation] base query - # @param includes [String] comma-separated list of IDs to include - # @return [ActiveRecord::Relation] modified query - def apply_includes(query, includes) - return query unless includes.present? - - includes.split(',').each do |id| - grid_table, object_table = grid_table_and_object_table(id) - next unless grid_table && object_table - - # Build a subquery that joins the grid table to the object table. - condition = <<-SQL.squish - EXISTS ( - SELECT 1 - FROM #{grid_table} - JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id - WHERE #{object_table}.granblue_id = ? - AND #{grid_table}.party_id = parties.id - ) - SQL - - query = query.where(condition, id) - end - - query - end - - # Applies the exclude conditions to query - # @param query [ActiveRecord::Relation] base query - # @return [ActiveRecord::Relation] modified query - def apply_excludes(query, excludes) - return query unless excludes.present? - - excludes.split(',').each do |id| - grid_table, object_table = grid_table_and_object_table(id) - next unless grid_table && object_table - - condition = <<-SQL.squish - NOT EXISTS ( - SELECT 1 - FROM #{grid_table} - JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id - WHERE #{object_table}.granblue_id = ? - AND #{grid_table}.party_id = parties.id - ) - SQL - - query = query.where(condition, id) - end - - query - end - - # == Query Filtering Helpers - - # Generates subquery for excluded characters - # @return [ActiveRecord::Relation, nil] exclusion query - def excluded_characters - return unless params[:excludes] - - excluded = params[:excludes].split(',').filter { |id| id[0] == '3' } - GridCharacter.includes(:object) - .where(characters: { granblue_id: excluded }) - .where('grid_characters.party_id = parties.id') - end - - # Generates subquery for excluded summons - # @return [ActiveRecord::Relation, nil] exclusion query - def excluded_summons - return unless params[:excludes] - - excluded = params[:excludes].split(',').filter { |id| id[0] == '2' } - GridSummon.includes(:object) - .where(summons: { granblue_id: excluded }) - .where('grid_summons.party_id = parties.id') - end - - # Generates subquery for excluded weapons - # @return [ActiveRecord::Relation, nil] exclusion query - def excluded_weapons - return unless params[:excludes] - - excluded = params[:excludes].split(',').filter { |id| id[0] == '1' } - GridWeapon.includes(:object) - .where(weapons: { granblue_id: excluded }) - .where('grid_weapons.party_id = parties.id') - end - - # == Query Processing - - # Fetches and processes parties query with pagination - # @param query [ActiveRecord::Relation] base query - # @return [ActiveRecord::Relation] processed and paginated parties - def fetch_parties(query) - query.order(created_at: :desc) - .paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) - .each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false } - end - - # Calculates total count for pagination - # @param query [ActiveRecord::Relation] current query - # @return [Integer] total count - def calculate_count(query) - # query.count.values.sum - query.count - end - - # Calculates total pages for pagination - # @param count [Integer] total record count - # @return [Integer] total pages - def calculate_total_pages(count) - # count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1 - (count.to_f / COLLECTION_PER_PAGE).ceil - end - - # == Include/Exclude Processing - - # Generates SQL for including specific items - # @param id [String] item identifier - # @return [String] SQL condition - def includes(id) - "(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')" - end - - # Generates SQL for excluding specific items - # @param id [String] item identifier - # @return [String] SQL condition - def excludes(id) - "(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')" - end - - # == Filter Condition Helpers - - # Generates user quality condition - # @return [String, nil] SQL condition for user quality - def user_quality - return if params[:user_quality].blank? || params[:user_quality] == 'false' - - 'user_id IS NOT NULL' - end - - # Generates name quality condition - # @return [String, nil] SQL condition for name quality - def name_quality - return if params[:name_quality].blank? || params[:name_quality] == 'false' - - low_quality = [ - 'Untitled', - 'Remix of Untitled', - 'Remix of Remix of Untitled', - 'Remix of Remix of Remix of Untitled', - 'Remix of Remix of Remix of Remix of Untitled', - 'Remix of Remix of Remix of Remix of Remix of Untitled', - '無題', - '無題のリミックス', - '無題のリミックスのリミックス', - '無題のリミックスのリミックスのリミックス', - '無題のリミックスのリミックスのリミックスのリミックス', - '無題のリミックスのリミックスのリミックスのリミックスのリミックス' - ] - joined_names = low_quality.map { |name| "'#{name}'" }.join(',') - "name NOT IN (#{joined_names})" - end - - # Generates original party condition - # @return [String, nil] SQL condition for original parties - def original - return if params['original'].blank? || params['original'] == 'false' - - 'source_party_id IS NULL' - end - - # == Filter Condition Helpers - - # Generates privacy condition based on favorites - # @param favorites [Boolean] whether viewing favorites - # @return [String, nil] SQL condition - def privacy(favorites: false) - return if admin_mode - - if favorites - 'visibility < 3' - else - 'visibility = 1' - end - end - - # == Utility Methods - - # Maps ID prefixes to table names - # @param id [String] item identifier - # @return [Array(String, String)] corresponding table name - def grid_table_and_object_table(id) - case id[0] - when '3' - %w[grid_characters characters] - when '2' - %w[grid_summons summons] - when '1' - %w[grid_weapons weapons] - else - [nil, nil] - end - end - - # Generates name for remixed party - # @param name [String] original party name - # @return [String] generated remix name - def remixed_name(name) - blanked_name = { - en: name.blank? ? 'Untitled team' : name, - ja: name.blank? ? '無名の編成' : name - } - - if current_user - case current_user.language - when 'en' - "Remix of #{blanked_name[:en]}" - when 'ja' - "#{blanked_name[:ja]}のリミックス" - end - else - "Remix of #{blanked_name[:en]}" - end - end - - # == Party Loading - - # Loads party by shortcode for routes using :id - # @return [void] + # Loads the party by its shortcode. def set_from_slug @party = Party.includes( - :user, - :job, - { raid: :group }, - { characters: [:character, :awakening] }, - { - weapons: { - # Eager load the associated weapon and its awakenings. - weapon: [:awakenings], - # Eager load the grid weapon’s own awakening (if applicable). - awakening: {}, - # Eager load any weapon key associations. - weapon_key1: {}, - weapon_key2: {}, - weapon_key3: {} - } + :user, :job, { raid: :group }, + { characters: %i[character awakening] }, + { weapons: { + weapon: [:awakenings], + awakening: {}, + weapon_key1: {}, + weapon_key2: {}, + weapon_key3: {} + } }, { summons: :summon }, - :guidebook1, - :guidebook2, - :guidebook3, - :source_party, - :remixes, - :skill0, - :skill1, - :skill2, - :skill3, - :accessory + :guidebook1, :guidebook2, :guidebook3, + :source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory ).find_by(shortcode: params[:id]) - render_not_found_response('party') unless @party end - # Loads party by ID for update/destroy actions - # @return [void] + # Loads the party by its id. def set @party = Party.where('id = ?', params[:id]).first end - # == Parameter Sanitization - - # Sanitizes and permits party parameters - # @return [Hash, nil] permitted parameters + # Sanitizes and permits party parameters. def party_params return unless params[:party].present? params.require(:party).permit( - :user_id, - :local_id, - :edit_key, - :extra, - :name, - :description, - :raid_id, - :job_id, - :visibility, - :accessory_id, - :skill0_id, - :skill1_id, - :skill2_id, - :skill3_id, - :full_auto, - :auto_guard, - :auto_summon, - :charge_attack, - :clear_time, - :button_count, - :turn_count, - :chain_count, - :guidebook1_id, - :guidebook2_id, - :guidebook3_id, - characters_attributes: [:id, :party_id, :character_id, :position, - :uncap_level, :transcendence_step, :perpetuity, - :awakening_id, :awakening_level, - { ring1: %i[modifier strength], ring2: %i[modifier strength], - ring3: %i[modifier strength], ring4: %i[modifier strength], + :user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility, + :accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id, + :full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count, + :turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id, + characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level, + :transcendence_step, :perpetuity, :awakening_id, :awakening_level, + { ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength], earring: %i[modifier strength] }], - summons_attributes: %i[id party_id summon_id position main friend - quick_summon uncap_level transcendence_step], - weapons_attributes: %i[id party_id weapon_id - position mainhand uncap_level transcendence_step element - weapon_key1_id weapon_key2_id weapon_key3_id - ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 - awakening_id awakening_level] + summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step], + weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level] ) end end diff --git a/app/controllers/concerns/party_authorization_concern.rb b/app/controllers/concerns/party_authorization_concern.rb new file mode 100644 index 0000000..af72ce0 --- /dev/null +++ b/app/controllers/concerns/party_authorization_concern.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module PartyAuthorizationConcern + extend ActiveSupport::Concern + + # Checks whether the current user (or provided edit key) is authorized to modify @party. + def authorize_party! + if @party.user.present? + render_unauthorized_response unless current_user.present? && @party.user == current_user + else + 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') + render_unauthorized_response unless valid_edit_key?(provided_edit_key, party_edit_key) + end + end + + # Returns true if the party does not belong to the current user. + def not_owner? + if @party.user + return true if current_user && @party.user != current_user + return true if current_user.nil? && edit_key.present? + else + return true if current_user.present? + return true if current_user.nil? && (@party.edit_key != edit_key) + end + false + end + + # Verifies that the provided edit key matches the party's edit key. + 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 +end diff --git a/app/controllers/concerns/party_preview_concern.rb b/app/controllers/concerns/party_preview_concern.rb new file mode 100644 index 0000000..15b2501 --- /dev/null +++ b/app/controllers/concerns/party_preview_concern.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PartyPreviewConcern + extend ActiveSupport::Concern + + # Schedules preview generation for this party. + def schedule_preview_generation + GeneratePartyPreviewJob.perform_later(id) + end + + # Handles serving the party preview image. + def party_preview(party) + coordinator = PreviewService::Coordinator.new(party) + if coordinator.generation_in_progress? + response.headers['Retry-After'] = '2' + default_path = Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png") + send_file default_path, type: 'image/png', disposition: 'inline' + return + end + begin + if Rails.env.production? + s3_object = coordinator.get_s3_object + send_data s3_object.body.read, filename: "#{party.shortcode}.png", type: 'image/png', disposition: 'inline' + else + send_file coordinator.local_preview_path, type: 'image/png', disposition: 'inline' + end + rescue Aws::S3::Errors::NoSuchKey + coordinator.schedule_generation unless coordinator.generation_in_progress? + send_file Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png"), type: 'image/png', disposition: 'inline' + end + end +end diff --git a/app/controllers/concerns/party_querying_concern.rb b/app/controllers/concerns/party_querying_concern.rb new file mode 100644 index 0000000..c8eb946 --- /dev/null +++ b/app/controllers/concerns/party_querying_concern.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module PartyQueryingConcern + extend ActiveSupport::Concern + include PartyConstants + + # Builds the base query for parties with all required associations. + def build_parties_query(favorites: false) + query = Party.includes( + { raid: :group }, + :job, + :user, + :skill0, :skill1, :skill2, :skill3, + :guidebook1, :guidebook2, :guidebook3, + { characters: :character }, + { weapons: :weapon }, + { summons: :summon } + ) + query = if favorites + query.joins(:favorites) + .where(favorites: { user_id: current_user.id }) + .distinct.order(created_at: :desc) + else + query.order(visibility: :asc, created_at: :desc) + end + query = apply_filters(query) + query = apply_privacy_settings(query, favorites: false) + query = apply_includes(query, params[:includes]) if params[:includes].present? + query = apply_excludes(query, params[:excludes]) if params[:excludes].present? + query + end + + # Renders paginated parties using PartyBlueprint. + def render_paginated_parties(parties) + render json: Api::V1::PartyBlueprint.render( + parties, + view: :preview, + root: :results, + meta: { + count: parties.total_entries, + total_pages: parties.total_pages, + per_page: PartyConstants::COLLECTION_PER_PAGE + }, + current_user: current_user + ) + end + + # Applies filters to the query. + def apply_filters(query) + conditions = build_filters + + query = query.where(conditions) + query = query.where(name_quality) if params[:name_quality].present? + query.where( + weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS, + characters_count: build_count(params[:characters_count], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS, + summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS + ) + end + + # Applies privacy settings based on whether the current user is an admin. + def apply_privacy_settings(query, favorites: false) + return query if admin_mode + + if favorites.present? + query.where('visibility < 3') + else + query.where(visibility: 1) + end + end + + # Builds filtering conditions from request parameters. + def build_filters + { + element: params[:element].present? ? params[:element].to_i : nil, + raid_id: params[:raid], + created_at: build_date_range, + full_auto: build_option(params[:full_auto]), + auto_guard: build_option(params[:auto_guard]), + charge_attack: build_option(params[:charge_attack]), + characters_count: build_count(params[:characters_count], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS, + summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS, + weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS + }.compact + end + + # Returns a date range based on the recency parameter. + def build_date_range + return nil unless params[:recency].present? + + start_time = DateTime.current - params[:recency].to_i.seconds + start_time.beginning_of_day..DateTime.current + end + + # Returns the count value or a default if blank. + def build_count(value, default) + value.blank? ? default : value.to_i + end + + # Processes an option parameter. + def build_option(value) + value.to_i unless value.blank? || value.to_i == -1 + end + + # Applies “includes” filtering for objects in the party. + def apply_includes(query, includes) + includes.split(',').each do |id| + grid_table, object_table = grid_table_and_object_table(id) + next unless grid_table && object_table + + condition = <<-SQL.squish + EXISTS ( + SELECT 1 FROM #{grid_table} + JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id + WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id + ) + SQL + query = query.where(condition, id) + end + query + end + + # Applies “excludes” filtering for objects in the party. + def apply_excludes(query, excludes) + excludes.split(',').each do |id| + grid_table, object_table = grid_table_and_object_table(id) + next unless grid_table && object_table + + condition = <<-SQL.squish + NOT EXISTS ( + SELECT 1 FROM #{grid_table} + JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id + WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id + ) + SQL + query = query.where(condition, id) + end + query + end + + # Maps an id’s prefix to the corresponding grid and object table names. + def grid_table_and_object_table(id) + case id[0] + when '3' then %w[grid_characters characters] + when '2' then %w[grid_summons summons] + when '1' then %w[grid_weapons weapons] + else [nil, nil] + end + end + + # Returns a remixed party name based on the current party name and current_user language. + def remixed_name(name) + blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name } + if current_user + case current_user.language + when 'en' then "Remix of #{blanked_name[:en]}" + when 'ja' then "#{blanked_name[:ja]}のリミックス" + else "Remix of #{blanked_name[:en]}" + end + else + "Remix of #{blanked_name[:en]}" + end + end +end diff --git a/app/errors/api/v1/no_character_provided_error.rb b/app/errors/api/v1/no_character_provided_error.rb new file mode 100644 index 0000000..efe7d76 --- /dev/null +++ b/app/errors/api/v1/no_character_provided_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + module V1 + class NoCharacterProvidedError < GranblueError + def http_status + 422 + end + + def code + 'no_character_provided' + end + + def message + 'A valid character must be provided' + end + + def to_hash + { + message: message, + code: code + } + end + end + end +end diff --git a/app/errors/api/v1/no_summon_provided_error.rb b/app/errors/api/v1/no_summon_provided_error.rb new file mode 100644 index 0000000..52d93a7 --- /dev/null +++ b/app/errors/api/v1/no_summon_provided_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + module V1 + class NoSummonProvidedError < GranblueError + def http_status + 422 + end + + def code + 'no_summon_provided' + end + + def message + 'A valid summon must be provided' + end + + def to_hash + { + message: message, + code: code + } + end + end + end +end diff --git a/app/errors/api/v1/no_weapon_provided_error.rb b/app/errors/api/v1/no_weapon_provided_error.rb new file mode 100644 index 0000000..a4ab984 --- /dev/null +++ b/app/errors/api/v1/no_weapon_provided_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + module V1 + class NoWeaponProvidedError < GranblueError + def http_status + 422 + end + + def code + 'no_weapon_provided' + end + + def message + 'A valid weapon must be provided' + end + + def to_hash + { + message: message, + code: code + } + end + end + end +end diff --git a/app/helpers/party_constants.rb b/app/helpers/party_constants.rb new file mode 100644 index 0000000..5d49336 --- /dev/null +++ b/app/helpers/party_constants.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +# +# This module contains shared constants used for querying and filtering Party resources. +# It is included by controllers and concerns that require these configuration values. +# +module PartyConstants + COLLECTION_PER_PAGE = 15 + DEFAULT_MIN_CHARACTERS = 3 + DEFAULT_MIN_SUMMONS = 2 + DEFAULT_MIN_WEAPONS = 5 + MAX_CHARACTERS = 5 + MAX_SUMMONS = 8 + MAX_WEAPONS = 13 + DEFAULT_MAX_CLEAR_TIME = 5400 +end diff --git a/app/models/character.rb b/app/models/character.rb index cd74ab1..a8a610e 100644 --- a/app/models/character.rb +++ b/app/models/character.rb @@ -48,73 +48,4 @@ class Character < ApplicationRecord def display_resource(character) character.name_en end - - # enum rarities: { - # R: 1, - # SR: 2, - # SSR: 3 - # } - - # enum elements: { - # Null: 0, - # Wind: 1, - # Fire: 2, - # Water: 3, - # Earth: 4, - # Dark: 5, - # Light: 6 - # } - - # enum proficiency1s: { - # Sabre: 1, - # Dagger: 2, - # Axe: 3, - # Spear: 4, - # Bow: 5, - # Staff: 6, - # Melee: 7, - # Harp: 8, - # Gun: 9, - # Katana: 10 - # }, _prefix: "proficiency1" - - # enum proficiency2s: { - # None: 0, - # Sabre: 1, - # Dagger: 2, - # Axe: 3, - # Spear: 4, - # Bow: 5, - # Staff: 6, - # Melee: 7, - # Harp: 8, - # Gun: 9, - # Katana: 10, - # }, _default: :None, _prefix: "proficiency2" - - # enum race1s: { - # Unknown: 0, - # Human: 1, - # Erune: 2, - # Draph: 3, - # Harvin: 4, - # Primal: 5 - # }, _prefix: "race1" - - # enum race2s: { - # Unknown: 0, - # Human: 1, - # Erune: 2, - # Draph: 3, - # Harvin: 4, - # Primal: 5, - # None: 6 - # }, _default: :None, _prefix: "race2" - - # enum gender: { - # Unknown: 0, - # Male: 1, - # Female: 2, - # "Male/Female": 3 - # } end diff --git a/app/models/concerns/granblue_enums.rb b/app/models/concerns/granblue_enums.rb new file mode 100644 index 0000000..6b85fca --- /dev/null +++ b/app/models/concerns/granblue_enums.rb @@ -0,0 +1,34 @@ +module GranblueEnums + extend ActiveSupport::Concern + + # Define constants for shared enum mappings. + RARITIES = { R: 1, SR: 2, SSR: 3 }.freeze + ELEMENTS = { Null: 0, Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 }.freeze + GENDERS = { Unknown: 0, Male: 1, Female: 2, "Male/Female": 3 }.freeze + + # Single proficiency enum mapping used for both proficiency1 and proficiency2. + PROFICIENCY = { + None: 0, + Sabre: 1, + Dagger: 2, + Axe: 3, + Spear: 4, + Bow: 5, + Staff: 6, + Melee: 7, + Harp: 8, + Gun: 9, + Katana: 10 + }.freeze + + # Single race enum mapping used for both race1 and race2. + RACES = { + Unknown: 0, + Human: 1, + Erune: 2, + Draph: 3, + Harvin: 4, + Primal: 5, + None: 6 + }.freeze +end diff --git a/app/models/grid_character.rb b/app/models/grid_character.rb index 36e93e1..98e5e80 100644 --- a/app/models/grid_character.rb +++ b/app/models/grid_character.rb @@ -1,24 +1,43 @@ # frozen_string_literal: true +## +# This file defines the GridCharacter model which represents a character's grid configuration within a party. +# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence. +# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem +# for duplicating records with specific attribute resets. +# +# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for +# these relationships and includes counter caches for performance optimization. +# +# @!attribute [r] character +# @return [Character] the associated character record. +# @!attribute [r] awakening +# @return [Awakening, nil] the associated awakening record (optional). +# @!attribute [r] party +# @return [Party] the associated party record. +# class GridCharacter < ApplicationRecord + # Associations belongs_to :character, foreign_key: :character_id, primary_key: :id - belongs_to :awakening, optional: true belongs_to :party, counter_cache: :characters_count, inverse_of: :characters + + # Validations validates_presence_of :party + # Validate that uncap_level and transcendence_step are present and numeric. + validates :uncap_level, presence: true, numericality: { only_integer: true } + validates :transcendence_step, presence: true, numericality: { only_integer: true } + validate :validate_awakening_level, on: :update validate :transcendence, on: :update validate :validate_over_mastery_values, on: :update validate :validate_aetherial_mastery_value, on: :update - validate :over_mastery_attack_matches_hp, on: :update - # Virtual attribute for the new rings structure + # Virtual attributes attr_accessor :new_rings - - # Virtual attribute for the new awakening structure attr_accessor :new_awakening ##### Amoeba configuration @@ -31,52 +50,121 @@ class GridCharacter < ApplicationRecord set perpetuity: false end + # Hooks before_validation :apply_new_rings, if: -> { new_rings.present? } before_validation :apply_new_awakening, if: -> { new_awakening.present? } - - # Add awakening before the model saves before_save :add_awakening + ## + # Validates the awakening level to ensure it falls within the allowed range. + # + # @note Triggered on update. + # @return [void] def validate_awakening_level errors.add(:awakening, 'awakening level too low') if awakening_level < 1 errors.add(:awakening, 'awakening level too high') if awakening_level > 9 end + ## + # Validates the transcendence step of the character. + # + # Ensures that the transcendence step is appropriate based on the character's ULB status. + # Adds errors if: + # - The character has a positive transcendence_step but no transcendence (ulb is false). + # - The transcendence_step exceeds the allowed maximum. + # - The transcendence_step is negative when character.ulb is true. + # + # @note Triggered on update. + # @return [void] def transcendence errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb end + ## + # Validates the over mastery attack value for ring1. + # + # Checks that if ring1's modifier is set, the strength must be one of the allowed attack values. + # Adds an error if the value is not valid. + # + # @return [void] def over_mastery_attack errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength']) end + ## + # Validates the over mastery HP value for ring2. + # + # If ring2's modifier is present, ensures that the strength is within the allowed HP values. + # Adds an error if the value is not valid. + # + # @return [void] def over_mastery_hp return if ring2['modifier'].nil? errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength']) end - def over_mastery_attack_matches_hp - return if ring1[:modifier].nil? && ring2[:modifier].nil? - - return if ring2[:strength] == (ring1[:strength] / 2) - - errors.add(:over_mastery, - 'over mastery attack and hp values do not match') + ## + # Validates over mastery values by invoking individual and cross-field validations. + # + # This method triggers: + # - Validation for individual over mastery values for rings 1-4. + # - Validation ensuring that ring1's attack and ring2's HP values are consistent. + # + # @return [void] + def validate_over_mastery_values + validate_individual_over_mastery_values + validate_over_mastery_attack_matches_hp end - def validate_over_mastery_values + ## + # Validates individual over mastery values for each ring (ring1 to ring4). + # + # Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength + # is within the permitted range based on over mastery rules. + # + # @return [void] + def validate_individual_over_mastery_values + # Iterate over rings 1-4 and check each ring’s value. [ring1, ring2, ring3, ring4].each_with_index do |ring, index| next if ring['modifier'].nil? - modifier = over_mastery_modifiers[ring['modifier']] - check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, - 'over_mastery') + # Use a helper to add errors if the value is out-of-range. + check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery') end end + ## + # Validates that the over mastery attack value matches the HP value appropriately. + # + # Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set, + # checks that ring2's strength is exactly half of ring1's strength. + # Adds an error if the values do not match. + # + # @return [void] + def validate_over_mastery_attack_matches_hp + # Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings) + # can be accessed uniformly. + r1 = ring1.with_indifferent_access + r2 = ring2.with_indifferent_access + # Only check if either ring has a modifier set. + if r1[:modifier].present? || r2[:modifier].present? + # Ensure that ring2's strength equals exactly half of ring1's strength. + unless r2[:strength].to_f == (r1[:strength].to_f / 2) + errors.add(:over_mastery, 'over mastery attack and hp values do not match') + end + end + end + + ## + # Validates the aetherial mastery value for the earring. + # + # If the earring's modifier is present and positive, it uses a helper method to check that the strength + # falls within the allowed range for aetherial mastery. + # + # @return [void] def validate_aetherial_mastery_value return if earring['modifier'].nil? @@ -87,22 +175,40 @@ class GridCharacter < ApplicationRecord 'aetherial_mastery') end + ## + # Returns the blueprint for rendering the grid character. + # + # @return [GridCharacterBlueprint] the blueprint class used for grid character representation. def blueprint GridCharacterBlueprint end private + ## + # Adds a default awakening to the character before saving if none is set. + # + # Retrieves the Awakening record with slug 'character-balanced' and assigns it. + # + # @return [void] def add_awakening return unless awakening.nil? self.awakening = Awakening.where(slug: 'character-balanced').sole end + ## + # Applies new ring configurations from the virtual attribute +new_rings+. + # + # Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength". + # Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to + # ring1, ring2, ring3, and ring4. + # + # @return [void] def apply_new_rings # Expect new_rings to be an array of hashes, e.g., # [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}] - default_ring = { "modifier" => nil, "strength" => nil } + default_ring = { 'modifier' => nil, 'strength' => nil } rings_array = Array(new_rings).map(&:to_h) # Pad with defaults so there are exactly four rings rings_array.fill(default_ring, rings_array.size...4) @@ -112,11 +218,29 @@ class GridCharacter < ApplicationRecord self.ring4 = rings_array[3] end + ## + # Applies new awakening configuration from the virtual attribute +new_awakening+. + # + # Sets the +awakening_id+ and +awakening_level+ based on the provided hash. + # + # @return [void] def apply_new_awakening self.awakening_id = new_awakening[:id] self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1 end + ## + # Checks that a given property value falls within the allowed range based on the specified mastery type. + # + # The +property+ parameter is expected to be a hash in the following format: + # { ring1: { atk: 300 } } + # + # Depending on the +type+, it validates against either over mastery or aetherial mastery values. + # Adds an error to the record if the value is not within the permitted range. + # + # @param property [Hash] the property hash containing the attribute and its value. + # @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery'). + # @return [void] def check_value(property, type) # Input format # { ring1: { atk: 300 } } @@ -135,6 +259,10 @@ class GridCharacter < ApplicationRecord end end + ## + # Returns a hash mapping over mastery modifier keys to their corresponding attribute names. + # + # @return [Hash{Integer => String}] mapping of modifier codes to attribute names. def over_mastery_modifiers { 1 => 'atk', @@ -155,6 +283,10 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns a hash containing allowed values for over mastery attributes. + # + # @return [Hash{Symbol => Array}] mapping of attribute names to their valid values. def over_mastery_values { atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], @@ -175,6 +307,9 @@ class GridCharacter < ApplicationRecord } end + # Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names. + # + # @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names. def aetherial_mastery_modifiers { 1 => 'da', @@ -190,6 +325,10 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns a hash containing allowed values for aetherial mastery attributes. + # + # @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values. def aetherial_mastery_values { da: { @@ -235,10 +374,18 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns an array of valid attack values for over mastery validation. + # + # @return [Array] list of allowed attack values. def atk_values [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000] end + ## + # Returns an array of valid HP values for over mastery validation. + # + # @return [Array] list of allowed HP values. def hp_values [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500] end diff --git a/app/models/grid_summon.rb b/app/models/grid_summon.rb index 5b1ed59..6bb2e8f 100644 --- a/app/models/grid_summon.rb +++ b/app/models/grid_summon.rb @@ -1,5 +1,15 @@ # frozen_string_literal: true +## +# Model representing a grid summon within a party. +# +# A GridSummon is associated with a specific {Summon} and {Party} and is responsible for +# enforcing rules on positions, uncap levels, and transcendence steps based on the associated summon’s flags. +# +# @!attribute [r] summon +# @return [Summon] the associated summon. +# @!attribute [r] party +# @return [Party] the associated party. class GridSummon < ApplicationRecord belongs_to :summon, foreign_key: :summon_id, primary_key: :id @@ -8,14 +18,35 @@ class GridSummon < ApplicationRecord inverse_of: :summons validates_presence_of :party + # Validate that position is provided. + validates :position, presence: true validate :compatible_with_position, on: :create + + # Validate that uncap_level and transcendence_step are present and numeric. + validates :uncap_level, presence: true, numericality: { only_integer: true } + validates :transcendence_step, presence: true, numericality: { only_integer: true } + + # Custom validation to enforce maximum uncap_level based on the associated Summon’s flags. + validate :validate_uncap_level_based_on_summon_flags + validate :no_conflicts, on: :create + ## + # Returns the blueprint for rendering the grid summon. + # + # @return [GridSummonBlueprint] the blueprint class for grid summons. def blueprint GridSummonBlueprint end - # Returns conflicting summons if they exist + ## + # Returns any conflicting grid summon for the given party. + # + # If the associated summon has a limit, this method searches the party's grid summons to find + # any that conflict based on the summon ID. + # + # @param party [Party] the party in which to check for conflicts. + # @return [GridSummon, nil] the conflicting grid summon if found, otherwise nil. def conflicts(party) return unless summon.limit @@ -28,13 +59,74 @@ class GridSummon < ApplicationRecord private - # Validates whether there is a conflict with the party + ## + # Validates the uncap_level based on the associated Summon’s flags. + # + # This method delegates to specific validation methods for FLB, ULB, and transcendence limits. + # + # @return [void] + def validate_uncap_level_based_on_summon_flags + return unless summon + + validate_flb_limit + validate_ulb_limit + validate_transcendence_limits + end + + ## + # Validates that the uncap_level does not exceed 3 if the associated Summon does not have the FLB flag. + # + # @return [void] + def validate_flb_limit + return unless !summon.flb && uncap_level.to_i > 3 + + errors.add(:uncap_level, 'cannot be greater than 3 if summon does not have FLB') + end + + ## + # Validates that the uncap_level does not exceed 4 if the associated Summon does not have the ULB flag. + # + # @return [void] + def validate_ulb_limit + return unless !summon.ulb && uncap_level.to_i > 4 + + errors.add(:uncap_level, 'cannot be greater than 4 if summon does not have ULB') + end + + ## + # Validates the uncap_level and transcendence_step based on whether the associated Summon supports transcendence. + # + # If the summon does not support transcendence, the uncap_level must not exceed 5 and the transcendence_step must be 0. + # + # @return [void] + def validate_transcendence_limits + return if summon.transcendence + + errors.add(:uncap_level, 'cannot be greater than 5 if summon does not have transcendence') if uncap_level.to_i > 5 + + return unless transcendence_step.to_i.positive? + + errors.add(:transcendence_step, 'must be 0 if summon does not have transcendence') + end + + ## + # Validates that there are no conflicting grid summons in the party. + # + # If a conflict is found (i.e. another grid summon exists that conflicts with this one), + # an error is added to the :series attribute. + # + # @return [void] def no_conflicts # Check if the grid summon conflicts with any of the other grid summons in the party errors.add(:series, 'must not conflict with existing summons') unless conflicts(party).nil? end - # Validates whether the summon can be added to the desired position + ## + # Validates whether the grid summon can be added to the desired position. + # + # For positions 4 and 5, the associated summon must have subaura; otherwise, an error is added. + # + # @return [void] def compatible_with_position return unless [4, 5].include?(position.to_i) && !summon.subaura diff --git a/app/models/grid_weapon.rb b/app/models/grid_weapon.rb index 615ed97..a618dda 100644 --- a/app/models/grid_weapon.rb +++ b/app/models/grid_weapon.rb @@ -1,6 +1,30 @@ # frozen_string_literal: true +## +# Model representing a grid weapon within a party. +# +# This model associates a weapon with a party and manages validations for weapon compatibility, +# conflict detection, and attribute adjustments such as determining if a weapon is mainhand. +# +# @!attribute [r] weapon +# @return [Weapon] the associated weapon. +# @!attribute [r] party +# @return [Party] the party to which the grid weapon belongs. +# @!attribute [r] weapon_key1 +# @return [WeaponKey, nil] the primary weapon key, if assigned. +# @!attribute [r] weapon_key2 +# @return [WeaponKey, nil] the secondary weapon key, if assigned. +# @!attribute [r] weapon_key3 +# @return [WeaponKey, nil] the tertiary weapon key, if assigned. +# @!attribute [r] weapon_key4 +# @return [WeaponKey, nil] the quaternary weapon key, if assigned. +# @!attribute [r] awakening +# @return [Awakening, nil] the associated awakening, if any. class GridWeapon < ApplicationRecord + # Allowed extra positions and allowed weapon series when in an extra position. + EXTRA_POSITIONS = [9, 10, 11].freeze + ALLOWED_EXTRA_SERIES = [11, 16, 17, 28, 29, 32, 34].freeze + belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id belongs_to :party, @@ -15,10 +39,14 @@ class GridWeapon < ApplicationRecord belongs_to :awakening, optional: true + # Validate that uncap_level and transcendence_step are present and numeric. + validates :uncap_level, presence: true, numericality: { only_integer: true } + validates :transcendence_step, presence: true, numericality: { only_integer: true } + validate :compatible_with_position, on: :create validate :no_conflicts, on: :create - before_save :mainhand? + before_save :assign_mainhand ##### Amoeba configuration amoeba do @@ -28,69 +56,99 @@ class GridWeapon < ApplicationRecord nullify :ax_strength2 end - # Helper methods + ## + # Returns the blueprint for rendering the grid weapon. + # + # @return [GridWeaponBlueprint] the blueprint class for grid weapons. def blueprint GridWeaponBlueprint end + ## + # Returns an array of assigned weapon keys. + # + # This method returns an array containing weapon_key1, weapon_key2, and weapon_key3, + # omitting any nil values. + # + # @return [Array] the non-nil weapon keys. def weapon_keys [weapon_key1, weapon_key2, weapon_key3].compact end - # Returns conflicting weapons if they exist + ## + # Returns conflicting grid weapons within a given party. + # + # Checks if the associated weapon is present, responds to a :limit method, and is limited. + # It then iterates over the party's grid weapons and selects those that conflict with this one, + # based on series matching or specific conditions related to opus or draconic status. + # + # @param party [Party] the party in which to check for conflicts. + # @return [ActiveRecord::Relation] an array of conflicting grid weapons (empty if none are found). def conflicts(party) - return unless weapon.limit + return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit - conflicting_weapons = [] - - party.weapons.each do |party_weapon| - next unless party_weapon.id + party.weapons.select do |party_weapon| + # Skip if the record is not persisted. + next false unless party_weapon.id.present? id_match = weapon.id == party_weapon.id series_match = weapon.series == party_weapon.weapon.series both_opus_or_draconic = weapon.opus_or_draconic? && party_weapon.weapon.opus_or_draconic? both_draconic = weapon.draconic_or_providence? && party_weapon.weapon.draconic_or_providence? - conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match + (series_match || both_opus_or_draconic || both_draconic) && !id_match end - - conflicting_weapons end private - # Conflict management methods - - # Validates whether the weapon can be added to the desired position + ## + # Validates whether the grid weapon is compatible with the desired position. + # + # For positions 9, 10, or 11 (considered extra positions), the weapon's series must belong to the allowed set. + # If the weapon is in an extra position but does not match an allowed series, an error is added. + # + # @return [void] def compatible_with_position - is_extra_position = [9, 10, 11].include?(position.to_i) - is_extra_weapon = [11, 16, 17, 28, 29, 32, 34].include?(weapon.series.to_i) + return unless weapon.present? - return unless is_extra_position - - return true if is_extra_weapon - - errors.add(:series, 'must be compatible with position') - false + if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i) + errors.add(:series, 'must be compatible with position') + end end - # Validates whether the desired weapon key can be added to the weapon + ## + # Validates that the assigned weapon keys are compatible with the weapon. + # + # Iterates over each non-nil weapon key and checks compatibility using the weapon's + # `compatible_with_key?` method. An error is added for any key that is not compatible. + # + # @return [void] def compatible_with_key weapon_keys.each do |key| errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key) end end - # Validates whether there is a conflict with the party + ## + # Validates that there are no conflicting grid weapons in the party. + # + # Checks if the current grid weapon conflicts with any other grid weapons within the party. + # If conflicting weapons are found, an error is added. + # + # @return [void] def no_conflicts - # Check if the grid weapon conflicts with any of the other grid weapons in the party - return unless !conflicts(party).nil? && !conflicts(party).empty? - - errors.add(:series, 'must not conflict with existing weapons') + conflicting = conflicts(party) + errors.add(:series, 'must not conflict with existing weapons') if conflicting.any? end - # Checks if the weapon should be a mainhand before saving the model - def mainhand? - self.mainhand = position == -1 + ## + # Determines if the grid weapon should be marked as mainhand based on its position. + # + # If the grid weapon's position is -1, sets the `mainhand` attribute to true. + # + # @return [void] + def assign_mainhand + self.mainhand = (position == -1) end end diff --git a/app/models/party.rb b/app/models/party.rb index 19f7dc1..fb88260 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -1,7 +1,86 @@ # frozen_string_literal: true +## +# This file defines the Party model which represents a party in the application. +# It encapsulates the logic for managing party records including associations with +# characters, weapons, summons, and other related models. The Party model handles +# validations, nested attributes, preview generation, and various business logic +# to ensure consistency and integrity of party data. +# +# @note The model uses ActiveRecord associations, enums, and custom validations. +# +# @!attribute [rw] preview_state +# @return [Integer] the current state of the preview, represented as an enum: +# - 0: pending +# - 1: queued +# - 2: in_progress +# - 3: generated +# - 4: failed +# @!attribute [rw] element +# @return [Integer] the elemental type associated with the party. +# @!attribute [rw] clear_time +# @return [Integer] the clear time for the party. +# @!attribute [rw] master_level +# @return [Integer, nil] the master level of the party. +# @!attribute [rw] button_count +# @return [Integer, nil] the button count, if applicable. +# @!attribute [rw] chain_count +# @return [Integer, nil] the chain count, if applicable. +# @!attribute [rw] turn_count +# @return [Integer, nil] the turn count, if applicable. +# @!attribute [rw] ultimate_mastery +# @return [Integer, nil] the ultimate mastery level, if applicable. +# @!attribute [rw] visibility +# @return [Integer] the visibility of the party: +# - 1: Public +# - 2: Unlisted +# - 3: Private +# @!attribute [rw] shortcode +# @return [String] a unique shortcode for the party. +# @!attribute [rw] edit_key +# @return [String] an edit key for parties without an associated user. +# +# @!attribute [r] source_party +# @return [Party, nil] the original party if this is a remix. +# @!attribute [r] remixes +# @return [Array] a collection of parties remixed from this party. +# @!attribute [r] user +# @return [User, nil] the user who created the party. +# @!attribute [r] raid +# @return [Raid, nil] the associated raid. +# @!attribute [r] job +# @return [Job, nil] the associated job. +# @!attribute [r] accessory +# @return [JobAccessory, nil] the accessory used in the party. +# @!attribute [r] skill0 +# @return [JobSkill, nil] the primary skill. +# @!attribute [r] skill1 +# @return [JobSkill, nil] the secondary skill. +# @!attribute [r] skill2 +# @return [JobSkill, nil] the tertiary skill. +# @!attribute [r] skill3 +# @return [JobSkill, nil] the quaternary skill. +# @!attribute [r] guidebook1 +# @return [Guidebook, nil] the first guidebook. +# @!attribute [r] guidebook2 +# @return [Guidebook, nil] the second guidebook. +# @!attribute [r] guidebook3 +# @return [Guidebook, nil] the third guidebook. +# @!attribute [r] characters +# @return [Array] the characters associated with this party. +# @!attribute [r] weapons +# @return [Array] the weapons associated with this party. +# @!attribute [r] summons +# @return [Array] the summons associated with this party. +# @!attribute [r] favorites +# @return [Array] the favorites that include this party. class Party < ApplicationRecord - ##### ActiveRecord Associations + include GranblueEnums + + # Define preview_state as an enum. + enum :preview_state, { pending: 0, queued: 1, in_progress: 2, generated: 3, failed: 4 } + + # ActiveRecord Associations belongs_to :source_party, class_name: 'Party', foreign_key: :source_party_id, @@ -87,7 +166,10 @@ class Party < ApplicationRecord before_create :set_shortcode before_create :set_edit_key - ##### Amoeba configuration + after_commit :update_element!, on: %i[create update] + after_commit :update_extra!, on: %i[create update] + + # Amoeba configuration amoeba do set weapons_count: 0 set characters_count: 0 @@ -102,45 +184,86 @@ class Party < ApplicationRecord include_association :summons end - ##### ActiveRecord Validations + # ActiveRecord Validations validate :skills_are_unique validate :guidebooks_are_unique - self.enum :preview_state, { - pending: 0, - queued: 1, - in_progress: 2, - generated: 3, - failed: 4 - } + # For element, validate numericality and inclusion using the allowed values from GranblueEnums. + validates :element, + numericality: { only_integer: true }, + inclusion: { + in: GranblueEnums::ELEMENTS.values, + message: "must be one of #{GranblueEnums::ELEMENTS.map { |name, value| "#{value} (#{name})" }.join(', ')}" + }, + allow_nil: true + + validates :clear_time, numericality: { only_integer: true } + validates :master_level, numericality: { only_integer: true }, allow_nil: true + validates :button_count, numericality: { only_integer: true }, allow_nil: true + validates :chain_count, numericality: { only_integer: true }, allow_nil: true + validates :turn_count, numericality: { only_integer: true }, allow_nil: true + validates :ultimate_mastery, numericality: { only_integer: true }, allow_nil: true + + # Validate visibility (allowed values: 1, 2, or 3). + validates :visibility, + numericality: { only_integer: true }, + inclusion: { + in: [1, 2, 3], + message: 'must be 1 (Public), 2 (Unlisted), or 3 (Private)' + } after_commit :schedule_preview_generation, if: :should_generate_preview? - def is_remix + ######################### + # Public API Methods + ######################### + + ## + # Checks if the party is a remix of another party. + # + # @return [Boolean] true if the party is a remix; false otherwise. + def remix? !source_party.nil? end - def remixes - Party.where(source_party_id: id) - end - + ## + # Returns the blueprint class used for rendering the party. + # + # @return [Class] the PartyBlueprint class. def blueprint PartyBlueprint end + ## + # Determines if the party is public. + # + # @return [Boolean] true if the party is public; false otherwise. def public? visibility == 1 end + ## + # Determines if the party is unlisted. + # + # @return [Boolean] true if the party is unlisted; false otherwise. def unlisted? visibility == 2 end + ## + # Determines if the party is private. + # + # @return [Boolean] true if the party is private; false otherwise. def private? visibility == 3 end - def is_favorited(user) + ## + # Checks if the party is favorited by a given user. + # + # @param user [User, nil] the user to check for favoritism. + # @return [Boolean] true if the party is favorited by the user; false otherwise. + def favorited?(user) return false unless user Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do @@ -148,99 +271,216 @@ class Party < ApplicationRecord end end + ## + # Determines if the party meets the minimum requirements for preview generation. + # + # The party must have at least one weapon, one character, and one summon. + # + # @return [Boolean] true if the party is ready for preview; false otherwise. def ready_for_preview? return false if weapons_count < 1 # At least 1 weapon return false if characters_count < 1 # At least 1 character return false if summons_count < 1 # At least 1 summon + true end + ## + # Determines whether a new preview should be generated for the party. + # + # The method checks various conditions such as preview state, expiration, and content changes. + # + # @return [Boolean] true if a preview generation should be triggered; false otherwise. def should_generate_preview? return false unless ready_for_preview? - # Always generate if no preview exists - return true if preview_state.nil? || preview_state == 'pending' - - # Generate if failed and enough time has passed for conditions to change - return true if preview_state == 'failed' && preview_generated_at < 5.minutes.ago - - # Generate if preview is old - return true if preview_state == 'generated' && preview_expired? - - # Only regenerate on content changes if the last generation was > 5 minutes ago - # This prevents rapid regeneration during party building - if preview_content_changed? - return true if preview_generated_at.nil? || preview_generated_at < 5.minutes.ago - end + return true if preview_pending? + return true if preview_failed_and_stale? + return true if preview_generated_and_expired? + return true if preview_content_changed_and_stale? false end + ## + # Checks whether the current preview has expired based on a predefined expiry period. + # + # @return [Boolean] true if the preview is expired; false otherwise. def preview_expired? preview_generated_at.nil? || preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago end + ## + # Determines if the content relevant for preview generation has changed. + # + # @return [Boolean] true if any preview-relevant attributes have changed; false otherwise. def preview_content_changed? saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) } end + ## + # Schedules the generation of a party preview if applicable. + # + # This method updates the preview state to 'queued' and enqueues a background job + # to generate the preview. + # + # @return [void] def schedule_preview_generation - return if preview_state == 'queued' || preview_state == 'in_progress' + return if %w[queued in_progress].include?(preview_state.to_s) - update_column(:preview_state, 'queued') + update_column(:preview_state, self.class.preview_states[:queued]) GeneratePartyPreviewJob.perform_later(id) end private - def set_shortcode - self.shortcode = random_string + ######################### + # Preview Generation Helpers + ######################### + + ## + # Checks if the preview is pending. + # + # @return [Boolean] true if preview_state is nil or 'pending'. + def preview_pending? + preview_state.nil? || preview_state == 'pending' end - def set_edit_key - return if user - - self.edit_key = Digest::SHA1.hexdigest([Time.now, rand].join) + ## + # Checks if the preview generation failed and the preview is stale. + # + # @return [Boolean] true if preview_state is 'failed' and preview_generated_at is older than 5 minutes. + def preview_failed_and_stale? + preview_state == 'failed' && preview_generated_at < 5.minutes.ago end - def random_string - num_chars = 6 - o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten - (0...num_chars).map { o[rand(o.length)] }.join + ## + # Checks if the generated preview is expired. + # + # @return [Boolean] true if preview_state is 'generated' and the preview is expired. + def preview_generated_and_expired? + preview_state == 'generated' && preview_expired? end + ## + # Checks if the preview content has changed and the preview is stale. + # + # @return [Boolean] true if the preview content has changed and preview_generated_at is nil or older than 5 minutes. + def preview_content_changed_and_stale? + preview_content_changed? && (preview_generated_at.nil? || preview_generated_at < 5.minutes.ago) + end + + ######################### + # Uniqueness Validation Helpers + ######################### + + ## + # Validates uniqueness for a given set of associations. + # + # @param associations [Array] an array of associated objects. + # @param attribute_names [Array] the corresponding attribute names for each association. + # @param error_key [Symbol] the key for a generic error. + # @return [void] + def validate_uniqueness_of_associations(associations, attribute_names, error_key) + filtered = associations.compact + return if filtered.uniq.length == filtered.length + + associations.each_with_index do |assoc, index| + next if assoc.nil? + + errors.add(attribute_names[index], 'must be unique') if associations[0...index].include?(assoc) + end + errors.add(error_key, 'must be unique') + end + + ## + # Validates that the selected skills are unique. + # + # @return [void] def skills_are_unique - skills = [skill0, skill1, skill2, skill3].compact - - return if skills.uniq.length == skills.length - - skills.each_with_index do |skill, index| - next if index.zero? - - errors.add(:"skill#{index + 1}", 'must be unique') if skills[0...index].include?(skill) - end - - errors.add(:job_skills, 'must be unique') + validate_uniqueness_of_associations([skill0, skill1, skill2, skill3], + [:skill0, :skill1, :skill2, :skill3], + :job_skills) end + ## + # Validates that the selected guidebooks are unique. + # + # @return [void] def guidebooks_are_unique - guidebooks = [guidebook1, guidebook2, guidebook3].compact - return if guidebooks.uniq.length == guidebooks.length - - guidebooks.each_with_index do |book, index| - next if index.zero? - - errors.add(:"guidebook#{index + 1}", 'must be unique') if guidebooks[0...index].include?(book) - end - - errors.add(:guidebooks, 'must be unique') + validate_uniqueness_of_associations([guidebook1, guidebook2, guidebook3], + [:guidebook1, :guidebook2, :guidebook3], + :guidebooks) end + ## + # Provides a list of attributes that are relevant for determining if the preview content has changed. + # + # @return [Array] an array of attribute names. def preview_relevant_attributes %w[ name job_id element weapons_count characters_count summons_count full_auto auto_guard charge_attack clear_time ] end + + ######################### + # Miscellaneous Helpers + ######################### + + ## + # Updates the party's element based on its main weapon. + # + # Finds the main weapon (position -1) and updates the party's element if it differs. + # + # @return [void] + def update_element! + main_weapon = weapons.detect { |gw| gw.position.to_i == -1 } + new_element = main_weapon&.weapon&.element + update_column(:element, new_element) if new_element.present? && self.element != new_element + end + + ## + # Updates the party's extra flag based on weapon positions. + # + # Sets the extra flag to true if any weapon is in an extra position, otherwise false. + # + # @return [void] + def update_extra! + new_extra = weapons.any? { |gw| GridWeapon::EXTRA_POSITIONS.include?(gw.position.to_i) } + update_column(:extra, new_extra) if self.extra != new_extra + end + + ## + # Sets a unique shortcode for the party before creation. + # + # Generates a random string and assigns it to the shortcode attribute. + # + # @return [void] + def set_shortcode + self.shortcode = random_string + end + + ## + # Sets an edit key for the party before creation if no associated user is present. + # + # The edit key is generated using a SHA1 hash based on the current time and a random value. + # + # @return [void] + def set_edit_key + return if user + + self.edit_key ||= Digest::SHA1.hexdigest([Time.now, rand].join) + end + + ## + # Generates a random alphanumeric string used for the party shortcode. + # + # @return [String] a random string of 6 characters. + def random_string + num_chars = 6 + o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten + (0...num_chars).map { o[rand(o.length)] }.join + end end diff --git a/app/services/preview_service/coordinator.rb b/app/services/preview_service/coordinator.rb index 748cbd8..5a31b04 100644 --- a/app/services/preview_service/coordinator.rb +++ b/app/services/preview_service/coordinator.rb @@ -7,9 +7,7 @@ module PreviewService PREVIEW_EXPIRY = 30.days GENERATION_TIMEOUT = 5.minutes LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews') - PREVIEW_DEBOUNCE_PERIOD = 5.minutes - PREVIEW_EXPIRY = 30.days # Public Interface - Core Operations diff --git a/config/application.rb b/config/application.rb index 5c0557b..43600bb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,8 @@ module HenseiApi config.active_record.query_log_tags = [:application, :controller, :action, :job] config.active_record.cache_query_log_tags = true + config.active_support.to_time_preserves_timezone = :zone + # API-only application configuration config.api_only = true end diff --git a/config/environments/test.rb b/config/environments/test.rb index 8b5160c..dd39c7a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,4 +1,4 @@ -require "active_support/core_ext/integer/time" +require 'active_support/core_ext/integer/time' # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that @@ -9,21 +9,21 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # Turn false under Spring and add config.action_view.cache_template_loading = true. - config.cache_classes = true + config.cache_classes = false # Eager loading loads your whole application. When running a single test locally, # this probably isn't necessary. It's a good idea to do in a continuous integration # system, or in some way before deploying your code. - config.eager_load = ENV["CI"].present? + config.eager_load = ENV['CI'].present? # Configure public file server for tests with Cache-Control for performance. config.public_file_server.enabled = true config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{1.hour.to_i}" + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" } # Show full error reports and disable caching. - config.consider_all_requests_local = true + config.consider_all_requests_local = true config.action_controller.perform_caching = false config.cache_store = :null_store diff --git a/config/initializers/oj.rb b/config/initializers/oj.rb new file mode 100644 index 0000000..5787a7b --- /dev/null +++ b/config/initializers/oj.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Explicitly configure Oj to mimic JSON. +Oj::Rails.mimic_JSON diff --git a/config/routes.rb b/config/routes.rb index 9ec0148..431bea1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,7 @@ Rails.application.routes.draw do get 'parties/favorites', to: 'parties#favorites' get 'parties/:id', to: 'parties#show' get 'parties/:id/preview', to: 'parties#preview' + get 'parties/:id/preview_status', to: 'parties#preview_status' post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview' post 'parties/:id/remix', to: 'parties#remix' diff --git a/db/seed/canonical.rb b/db/seed/canonical.rb new file mode 100644 index 0000000..2e60243 --- /dev/null +++ b/db/seed/canonical.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'csv' + +# Helper: Process boolean columns. +def process_booleans(attrs, columns) + columns.each do |col| + next unless attrs.key?(col) && attrs[col].present? + attrs[col] = ActiveModel::Type::Boolean.new.cast(attrs[col]) + end +end + +# Helper: Process date columns. +def process_dates(attrs, columns) + columns.each do |col| + next unless attrs.key?(col) && attrs[col].present? + attrs[col] = Date.parse(attrs[col]) rescue nil + end +end + +# Simplified CSV loader for a given model. +def load_csv_for(model_class, csv_filename, unique_key = :granblue_id) + csv_file = Rails.root.join('db', 'seed', 'test', csv_filename) + puts "Loading #{model_class.name} data from #{csv_file}..." + CSV.foreach(csv_file, headers: true) do |row| + attrs = row.to_hash.symbolize_keys + process_booleans(attrs, %i[flb ulb subaura limit transcendence]) + process_dates(attrs, %i[release_date flb_date ulb_date transcendence_date]) + attrs.each { |k, v| attrs[k] = nil if v.is_a?(String) && v.strip.empty? } + attrs.except!(:id) + model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r| + r.assign_attributes(attrs.except(unique_key)) + end + end +end + +# Load canonical data for each model. +load_csv_for(Awakening, 'awakenings_test.csv', :slug) +load_csv_for(Summon, 'summons_test.csv', :granblue_id) +load_csv_for(Weapon, 'weapons_test.csv', :granblue_id) +load_csv_for(Character, 'characters_test.csv', :granblue_id) diff --git a/db/seed/test/awakenings_test.csv b/db/seed/test/awakenings_test.csv new file mode 100644 index 0000000..6e654ea --- /dev/null +++ b/db/seed/test/awakenings_test.csv @@ -0,0 +1,11 @@ +"id","name_en","name_jp","slug","object_type","order" +"6e233877-8cda-4c8f-a091-3db6f68749e2","Attack","攻撃","character-atk","Character",1 +"b1847c82-ece0-4d7a-8af1-c7868d90f34a","Balanced","バランス","character-balanced","Character",0 +"c95441de-f949-4a62-b02b-101aa2e0a638","Defense","防御","character-def","Character",2 +"e36b0573-79c3-4dd2-9524-c95def4bbb1a","Multiattack","連続攻撃","character-multi","Character",3 +"d691a61c-dc7e-4d92-a8e6-98c04608353c","Attack","攻撃","weapon-atk","Weapon",1 +"a60b8356-ec37-4f8b-a188-a3d48803ac76","C.A.","奥義","weapon-ca","Weapon",4 +"969d37db-5f14-4d1a-bef4-59ba5a016674","Defense","防御","weapon-def","Weapon",2 +"26a47007-8886-476a-b6c0-b56c8fcdb09f","Healing","回復","weapon-heal","Weapon",5 +"18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7","Skill DMG","アビダメ","weapon-skill","Weapon",6 +"275c9de5-db1e-4c66-8210-660505fd1af4","Special","特殊","weapon-special","Weapon",3 diff --git a/db/seed/test/characters_test.csv b/db/seed/test/characters_test.csv new file mode 100644 index 0000000..1dbb070 --- /dev/null +++ b/db/seed/test/characters_test.csv @@ -0,0 +1,6 @@ +"id","name_en","name_jp","granblue_id","release_date","wiki_en","wiki_ja","rarity","element","proficiency1","proficiency2","gender","race1","race2","flb","min_hp","max_hp","max_hp_flb","min_atk","max_atk","max_atk_flb","base_da","base_ta","ougi_ratio","ougi_ratio_flb","special","ulb","max_hp_ulb","max_atk_ulb","character_id","nicknames_en","nicknames_jp","flb_date","ulb_date","gamewith","kamigame" +"9ad10c6f-83cd-4de3-a1ec-829efe0ac83b","Rosamia (SSR)","ロザミア(SSR)","3040087000","2016-06-30","Rosamia (SSR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SSR%29",3,6,1,,2,1,,FALSE,300,1600,,300,1600,,10,5,4.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"33985","SSR/ロザミア" +"afd282c7-ba4d-4213-b039-4ae7b71ef26e","Rosamia","ロザミア","3020018000","2014-03-10","Rosamia","%A5%ED%A5%B6%A5%DF%A5%A2%20%28R%29",1,6,1,,2,1,,FALSE,210,840,,950,3800,,,,,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21155","R/ロザミア(R)" +"d5fb1b79-483f-44cf-8437-92ce31f5f2b2","Rosamia (SR)","ロザミア(SR)","3030049000","2014-12-31","Rosamia (SR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SR%29",2,6,1,,2,1,,FALSE,260,1300,,260,1300,,7,3,3.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21090","SR/ロザミア" +"24bf1c09-509f-4db1-b953-b95ebcc69fb9","Seofon","シエテ","3040036000","2015-04-16","Seofon","%A5%B7%A5%A8%A5%C6%20%28SSR%29",3,1,1,,1,1,,TRUE,237,1477,1777,1777,10777,12777,10,5,4.5,5,FALSE,TRUE,14777,1977,"{4007}","{siete}","{}","2017-03-20","2021-06-29","21117","SSR/シエテ" +"b1eae4fe-e35c-44da-aa4f-7ca1c3e5863f","Zeta","ゼタ","3040028000","2014-12-31","Zeta","%A5%BC%A5%BF%20%28SSR%29",3,2,4,,2,1,,TRUE,240,1280,1520,240,1280,1520,10,5,5,,FALSE,FALSE,,,"{3024}","{}","{}","2020-01-28",,"21290","SSR/ゼタ" diff --git a/db/seed/test/summons_test.csv b/db/seed/test/summons_test.csv new file mode 100644 index 0000000..54fc721 --- /dev/null +++ b/db/seed/test/summons_test.csv @@ -0,0 +1,10 @@ +"id","name_en","name_jp","granblue_id","rarity","element","series","flb","ulb","max_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","subaura","limit","transcendence","max_atk_xlb","max_hp_xlb","summon_id","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence_date","nicknames_en","nicknames_jp" +"6db3991a-e72a-41fc-bad5-c7cc6e7f39ff","Orologia","オロロジャイア","2040433000",3,5,"0",TRUE,FALSE,150,145,836,1182,,310,2023,2880,,TRUE,FALSE,FALSE,,,3341,"2024-12-31","2024-12-31",,"Orologia_(Summon)","オロロジャイア (SSR)","479773","オロロジャイア",,"{}","{}" +"eaed64ed-2b1b-4f9f-a572-2032bbff197d","Wedges of the Sky","蒼空の楔","2040430000",3,1,,FALSE,FALSE,100,109,668,,,277,1740,,,TRUE,FALSE,FALSE,,,3338,"2024-08-09",,,"Wedges_of_the_Sky","蒼空の楔 (SSR)","458492","蒼空の楔",,"{boost,dragons}","{}" +"7d303689-3eb1-4193-80cd-e15de46af9d4","Beelzebub (Summer)","ベルゼバブ(水着ver)","2040429000",3,3,,TRUE,FALSE,150,115,793,1132,,442,2137,2985,,TRUE,FALSE,FALSE,,,3319,"2024-07-31","2024-07-31",,"Beelzebub_(Summer)","ベルゼバブ (SSR)水着","458158","水着ベルゼバブ",,"{babu,bubz}","{}" +"d169eaba-5493-45d4-a2f8-966850d784c9","Lu Woh","ル・オー","2040409000",3,6,"12",TRUE,FALSE,100,155,880,1243,,255,1800,2573,,TRUE,FALSE,FALSE,,,,"2023-02-28",,,"Lu Woh","%A5%EB%A1%A6%A5%AA%A1%BC%20%28SSR%29","385068","SSR/ルオー",,"{}","{}" +"42899ba7-dbf4-433f-8dd4-805010a1a627","Yatima","ヤチマ","2040417000",3,3,"0",TRUE,FALSE,150,138,1155,,,327,2870,,,TRUE,TRUE,FALSE,,,,"2022-12-31","2022-12-31",,"Yatima","%BE%A4%B4%AD%C0%D0%2F%A5%E4%A5%C1%A5%DE%20%28SSR%29","381024","SSR/ヤチマ",,"{}","{}" +"ad9abfc0-d919-4f75-aa15-0993d20d85ba","Beelzebub","ベルゼバブ","2040408000",3,5,"0",TRUE,FALSE,150,122,761,1081,,407,2317,3272,,TRUE,FALSE,FALSE,,,,"2021-12-31","2021-12-31",,"Beelzebub","%BE%A4%B4%AD%C0%D0%2F%A5%D9%A5%EB%A5%BC%A5%D0%A5%D6%20%28SSR%29","314288","ベルゼバブ",,"{bubz,bubs}","{}" +"0156d098-f5e7-4d72-8d14-34e6adff9280","The Moon","ザ・ムーン","2040243000",3,3,"6",TRUE,TRUE,200,110,668,952,1094,110,668,952,1094,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Moon (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%E0%A1%BC%A5%F3%20%28SSR%29","81835","SSR/ザ・ムーン",,"{}","{}" +"05214d59-2765-40c3-9a1d-6c29c6bdc6d6","Colossus Omega","コロッサス・マグナ","2040034000",3,2,"2",TRUE,TRUE,200,103,648,778,921,275,1635,1965,2315,TRUE,FALSE,TRUE,2665,1064,2001,"2014-10-08","2018-03-10","2020-08-08","Colossus Omega","%BE%A4%B4%AD%C0%D0%2F%A5%B3%A5%ED%A5%C3%A5%B5%A5%B9%A1%A6%A5%DE%A5%B0%A5%CA%20%28SSR%29","21736","SSR/コロッサス・マグナ","2024-05-02","{}","{}" +"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}" diff --git a/db/seed/test/weapons_test.csv b/db/seed/test/weapons_test.csv new file mode 100644 index 0000000..08cf34f --- /dev/null +++ b/db/seed/test/weapons_test.csv @@ -0,0 +1,42 @@ +"id","name_en","name_jp","granblue_id","rarity","element","proficiency","series","flb","ulb","max_level","max_skill_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","extra","ax_type","limit","ax","nicknames_en","nicknames_jp","max_awakening_level","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence","transcendence_date","recruits" +"6c4f29c8-f43b-43f1-9fc5-967fb85c816e","Gauntlet of Proudearth","揺るがぬ大地の拳","1040611300",3,4,7,0,TRUE,FALSE,150,15,35,240,290,,390,2300,2780,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-04-24","2019-04-24",,"Gauntlet of Proudearth",,,,FALSE,, +"302ded88-b5c9-4570-b422-66fc40277c4f","Ixaba","イクサバ","1040906400",3,2,10,1,TRUE,FALSE,150,15,30,195,236,,502,3000,3620,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-03-31","2018-05-21",,"Ixaba",,"72189","イクサバ",FALSE,,"3040115000" +"b540fbaf-48c9-41c0-981f-05953319b409","Skeletal Eclipse","呪蝕の骸槍","1040216900",3,5,4,1,TRUE,FALSE,150,15,43,280,339,,441,2547,3074,,FALSE,0,FALSE,FALSE,"{}","{}",,"2021-12-31","2021-12-31",,"Skeletal Eclipse","%C9%F0%B4%EF%2F%BC%F6%BF%AA%A4%CE%B3%BC%C1%E4%20%28SSR%29","314295","呪蝕の骸槍",FALSE,,"3040376000" +"aa6f8b9b-ed78-4b1a-8693-acefd5b455fc","Scythe of Repudiation","絶対否定の大鎌","1040310600",3,2,3,2,TRUE,TRUE,200,20,30,195,236,277,450,2730,3300,3870,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Scythe of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15", +"c6e4eeaa-bd19-466e-81ea-58310ed5cf25","Draconic Blade","ドラゴニックブレイド","1040912100",3,6,10,3,TRUE,TRUE,200,20,32,193,233,273,445,2744,3319,3894,FALSE,0,TRUE,FALSE,"{}","{}",,"2020-03-10","2020-03-10","2020-03-10","Draconic Blade","%C9%F0%B4%EF%2F%A5%C9%A5%E9%A5%B4%A5%CB%A5%C3%A5%AF%A5%D6%A5%EC%A5%A4%A5%C9%20%28SSR%29","190367",,FALSE,, +"1cedbb93-79ef-41ef-915f-94961ef9eba8","Nine-Realm Harp (Awakened)","九界琴・覚醒","1040801400",3,0,8,4,FALSE,FALSE,100,10,75,275,,,380,2470,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10",,,"Nine-Realm Harp (Awakened)",,,,FALSE,, +"a5d72b41-6dea-4179-9996-36c01d2dad32","Winter's Frostnettle","冬ノ霜柱","1040111300",3,3,2,5,TRUE,FALSE,150,15,21,189,228,,290,1857,2249,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-07-12","2019-07-12",,"Winter%27s Frostnettle","%C9%F0%B4%EF%2F%C5%DF%A5%CE%C1%FA%C3%EC%20%28SSR%29","158278","冬ノ霜柱",FALSE,, +"620fbcd5-7c2e-4949-8cad-bbfb0908b00f","Ecke Sachs","エッケザックス","1040007100",3,2,1,6,TRUE,FALSE,150,15,106,664,800,,278,1677,2030,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2020-04-07",,"Ecke Sachs","%C9%F0%B4%EF%2F%A5%A8%A5%C3%A5%B1%A5%B6%A5%C3%A5%AF%A5%B9%20%28SSR%29","71702","エッケザックス",FALSE,, +"8cebe3c3-be12-4985-b45d-3e9db8204e6e","Ray of Zhuque Malus","朱雀光剣・邪","1040906700",3,2,10,7,TRUE,TRUE,200,20,22,145,175,205,345,2090,2530,2970,FALSE,0,TRUE,FALSE,"{}","{}",,"2017-04-10","2017-04-10","2022-04-07","Ray of Zhuque Malus",,"75564","朱雀光剣・邪",FALSE,, +"4380828f-1acc-46cd-b7eb-1cb8d34ca9ec","Last Storm Harp","ラストストームハープ","1040808300",3,1,8,8,TRUE,FALSE,150,15,62,223,260,,337,2059,2400,,FALSE,0,FALSE,FALSE,"{}","{}",,"2018-03-10","2018-03-10",,"Last Storm Harp",,,,FALSE,, +"ec3ba18a-9417-4ebe-a898-a74d5f15385f","Pillar of Flame","炎の柱","1040215200",3,6,4,8,TRUE,FALSE,150,15,37,213,250,,341,2250,2630,,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-08-31","2020-08-31",,"Pillar of Flame","%C9%F0%B4%EF%2F%B1%EA%A4%CE%C3%EC%20%28SSR%29","225789","炎の柱",FALSE,, +"d61ee84f-4520-4064-8ff9-42a899273316","Luminiera Sword Omega","シュヴァリエソード・マグナ","1040007200",3,6,1,9,TRUE,TRUE,200,20,31,195,228,244,370,2275,2660,2850,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Luminiera Sword Omega",,,,FALSE,, +"9f94d1e5-a117-432f-9da4-f3a5022b666d","Bow of Sigurd","シグルズの弓","1040705100",3,3,5,10,TRUE,FALSE,150,15,36,214,250,,365,2311,2701,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2018-07-15",,"Bow of Sigurd",,,,FALSE,, +"82deb08e-8f92-44eb-8671-22426f89564e","Sword of Pallas Militis","パラスソード・ミーレス","1040022600",3,2,1,11,FALSE,FALSE,100,10,28,182,,,355,2153,,,TRUE,0,FALSE,FALSE,"{}","{}",,"2022-02-21",,,"Sword of Pallas Militis","%C9%F0%B4%EF%2F%A5%D1%A5%E9%A5%B9%A5%BD%A1%BC%A5%C9%A1%A6%A5%DF%A1%BC%A5%EC%A5%B9%20%28SSR%29","319816",,FALSE,, +"88492bc9-8085-4651-8a9d-305ab03d0710","Hollowsky Bow","虚空の歪弦","1040708900",3,6,5,12,TRUE,FALSE,150,15,37,234,280,,420,2580,3120,,FALSE,0,TRUE,FALSE,"{}","{}",,"2018-12-18","2018-12-18",,"Hollowsky Bow",,"134591","虚空の歪弦",FALSE,, +"9adb22c7-eb09-47e6-b100-783e0cefaf95","Last Sahrivar","ラスト・シャフレワル","1040015800",3,4,1,13,TRUE,TRUE,200,20,39,200,232,264,391,2240,2611,2982,FALSE,2,FALSE,TRUE,"{}","{}",,"2018-05-15","2018-05-15","2022-06-07","Last Sahrivar",,"105147",,FALSE,, +"6d4b41c6-2807-4aa6-9f69-14f5c2c68f37","Claws of Terror","黒銀の滅爪","1040612500",3,5,7,14,TRUE,TRUE,200,20,33,227,266,305,372,2196,2561,2926,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-03-03","2020-03-03","2020-05-25","Claws of Terror",,"187437",,FALSE,, +"874eaf0b-5561-49d4-8983-ded494642a84","Rose Crystal Sword","ローズクリスタルソード","1040009700",3,3,1,15,FALSE,FALSE,100,10,34,204,,,365,2320,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2017-03-10",,,"Rose Crystal Sword",,,,FALSE,, +"e65ddc21-b8e9-45ee-8c0f-06013b4187a1","Spear of Bahamut Coda","バハムートスピア・フツルス","1040205400",3,5,4,16,TRUE,TRUE,200,20,37,248,290,332,380,2260,2640,3020,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10",,"2021-12-03","Spear of Bahamut Coda",,,,FALSE,, +"07dd062a-640c-4f00-9943-614b9f031271","Ultima Claw","オメガクロー","1040608100",3,0,7,17,TRUE,TRUE,200,20,35,277,313,349,393,2717,3066,3415,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10","2021-12-03","2021-12-03","Ultima Claw",,,,FALSE,, +"0c21542c-ce18-471e-ac80-1378fc97bec8","Scales of Dominion","支配の天秤","1040415800",3,5,6,18,TRUE,FALSE,150,15,38,261,320,,345,2020,2440,,FALSE,0,FALSE,FALSE,"{}","{}",,"2017-07-24","2019-08-06",,"Scales of Dominion",,"161169",,FALSE,, +"54c220d4-9cee-4f42-b184-5057cb1cb24a","Esna","エスナ","1040420600",3,3,6,19,TRUE,FALSE,150,15,50,294,355,,288,1859,2252,,FALSE,0,FALSE,FALSE,"{}","{}",15,"2022-07-20","2022-07-20",,"Esna","%C9%F0%B4%EF%2F%A5%A8%A5%B9%A5%CA%20%28SSR%29","352615",,FALSE,, +"742f29a3-2fa0-40f9-9275-126d892501b3","Cosmic Blade","ブレイド・オブ・コスモス","1040911800",3,6,10,20,TRUE,FALSE,150,15,31,184,222,,423,2610,3157,,FALSE,0,FALSE,FALSE,"{}","{}",,"2016-03-10","2019-12-19",,"Cosmic Blade",,,,FALSE,, +"c250d5c7-0208-49b5-9c88-fc51117dd7d3","Ewiyar's Beak","イーウィヤピーク","1040912400",3,1,10,21,TRUE,FALSE,150,15,31,192,224,,450,2749,3209,,FALSE,1,FALSE,TRUE,"{}","{}",,"2020-07-27","2020-07-27",,"Ewiyar%27s Beak",,"218570",,FALSE,, +"52d41363-16b1-42af-b185-ed1ba1308891","Ameno Habakiri","天羽々斬","1040904300",3,0,10,22,TRUE,TRUE,200,20,37,213,250,287,504,3024,3530,4036,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10","2018-04-17","2020-05-11","Ameno Habakiri","%C9%F0%B4%EF%C9%BE%B2%C1%2F%A5%B9%A5%DA%A5%EA%A5%AA%A5%EB%A5%B7%A5%EA%A1%BC%A5%BA","75523","天羽々斬",FALSE,, +"a273dcbf-4d85-4898-89ac-41cc80c262d7","Gisla","グラーシーザー","1040200700",3,5,4,23,TRUE,FALSE,150,15,39,255,309,,404,2325,2810,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-05-14","2015-07-27",,"Gisla",,,,FALSE,, +"eeb5882d-63a1-4852-a753-32166b4b9b7f","Wasserspeier","ヴァッサーシュパイア","1040018100",3,0,1,24,TRUE,TRUE,200,20,32,205,240,275,390,2410,2814,3218,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-05-10","2019-05-10","2019-05-10","Wasserspeier","%C9%F0%B4%EF%2F%A5%F4%A5%A1%A5%C3%A5%B5%A1%BC%A5%B7%A5%E5%A5%D1%A5%A4%A5%A2%A1%BC%20%28SSR%29","150388","ヴァッサーシュパイアー",FALSE,, +"8137294e-6bf1-4bac-a1e0-38cdc542622b","Clarion","クラリオン","1040511200",3,3,9,25,TRUE,TRUE,200,20,27,163,200,237,385,2425,2940,3455,FALSE,0,FALSE,FALSE,"{}","{}",15,"2019-06-04","2019-06-04","2022-08-19","Clarion","%C9%F0%B4%EF%2F%A5%AF%A5%E9%A5%EA%A5%AA%A5%F3%20%28SSR%29","153291",,FALSE,, +"63a066c7-9f23-4c12-a921-ec56f584b0ed","Kaladanda","カラダンダ","1040416300",3,2,6,26,TRUE,FALSE,150,15,41,264,320,,333,2009,2428,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-12-19","2019-12-19",,"Kaladanda","%C9%F0%B4%EF%2F%A5%AB%A5%E9%A5%C0%A5%F3%A5%C0%20%28SSR%29","180277","カラダンダ",FALSE,, +"ac8da736-4041-45e2-b413-f859e6fae828","Magma Rush","マグマストリーム","1040408100",3,2,6,27,FALSE,FALSE,150,15,36,232,,,295,1770,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2016-06-09","2023-07-12",,"Magma Rush",,"72005",,FALSE,, +"aec45e41-9874-465b-b668-9129a49d40c5","Sephira Emerald Duke","セフィラの翠甲","1040610000",3,4,7,28,TRUE,FALSE,150,15,38,232,271,,350,2175,2540,,TRUE,0,FALSE,FALSE,"{}","{}",,"2018-03-22","2020-12-04",,"Sephira Emerald Duke",,,,FALSE,, +"7f70e52a-d18c-4353-a135-1a841d3b7bf2","Rise of Justice","ライズ・オブ・ジャスティス","1040020800",3,3,1,29,TRUE,TRUE,200,20,34,202,244,286,367,2322,2811,3300,TRUE,0,FALSE,FALSE,"{}","{}",,"2020-12-04","2022-02-21","2022-12-26","Rise of Justice","%C9%F0%B4%EF%2F%A5%E9%A5%A4%A5%BA%A1%A6%A5%AA%A5%D6%A1%A6%A5%B8%A5%E3%A5%B9%A5%C6%A5%A3%A5%B9%20%28SSR%29","{{{link_gamewith|220273}}}",,FALSE,, +"e7a05d2e-a3ec-4620-98a5-d8472d474971","Fang of the Dragonslayer Mk II","竜伐の剛牙・再誕","1040117700",3,4,2,30,TRUE,FALSE,150,15,41,247,325,,394,2456,3230,,FALSE,,FALSE,FALSE,"{}","{}",20,"2023-11-09","2023-11-09",,"Fang of the Dragonslayer Mk II","","","",FALSE,, +"af83ceee-3a24-48c7-8cae-9f83276ced81","Hraesvelgr","フレズヴェルク","1040515200",3,3,9,31,TRUE,FALSE,150,15,37,211,246,,514,3131,3654,,FALSE,0,FALSE,FALSE,"{}","{}",,"2022-12-26","2022-12-26",,"Hraesvelgr","%C9%F0%B4%EF%2F%A5%D5%A5%EC%A5%BA%A5%F4%A5%A7%A5%EB%A5%AF%20%28SSR%29","366906",,FALSE,, +"47208685-e87a-4e07-b328-fb9ac3888718","Worldscathing Leon","レオン・オブ・ワールド","1040815100",3,2,8,32,TRUE,FALSE,150,15,44,281,350,,379,2286,2763,,TRUE,,FALSE,FALSE,"{}","{}",10,"2023-03-30","2023-12-19",,"Worldscathing Leon",,"393901",,FALSE,, +"a2f0db22-baf1-4640-8c2e-6d283375744f","Exo Antaeus","神銃エクス・アンタイオス","1040516300",3,3,9,33,TRUE,FALSE,150,15,29,169,204,,394,2488,3012,,FALSE,,FALSE,FALSE,"{}","{}",10,"2023-09-07","2023-09-07",,"Exo Antaeus","","","",FALSE,, +"b9522d2d-1d29-4a2b-b58c-d3b7c781feb6","Prayer of Grand Gales","狂飆と至高の祈り","1040422200",3,1,6,34,TRUE,TRUE,200,20,39,236,285,439,412,2566,3105,3302,TRUE,,TRUE,FALSE,"{draconic}","{ドラポン}",,"2023-10-23",,,"Prayer of Grand Gales","","","",FALSE,, +"81b9845a-a6d5-4aec-bbcf-1678277c1d79","Albacore Body","アルバコアボディ","1040423500",3,5,6,35,FALSE,FALSE,100,10,32,224,,,303,1759,,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-07-29",,,"Albacore Body","アルバコアボディ (SSR)","458282","アルバコアボディ",FALSE,, +"f0d13eb4-f462-48d8-8705-16f91c351cb2","Syringe or Treat","シリンジ・オア・トリート","1040516400",3,5,9,36,FALSE,FALSE,100,10,19,130,,,504,2930,,,FALSE,,FALSE,FALSE,"{}","{}",,"2023-10-18",,,"Syringe or Treat","","","",FALSE,,"3040487000" +"3d9fad4c-a34f-4133-9d5e-c382a747eeec","Demolition-Tiger Axe","絶壊・威寅斧","1040319100",3,4,3,37,TRUE,FALSE,150,15,35,207,250,,456,2834,3429,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-11-07","2024-11-07",,"Demolition-Tiger_Axe","絶壊・威寅斧 (SSR)","471097","絶壊・威寅斧",FALSE,, +"4110e59e-5b4c-40f8-ad83-2e62f5d60fc2","Yggdrasil Crystal Blade Arbos","世界樹の晶剣・アルボス","1040026300",3,4,1,38,TRUE,FALSE,150,15,32,200,242,,377,2332,2821,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-06-03","2024-06-03",,"Yggdrasil Crystal Blade Arbos","","","",FALSE,, diff --git a/sig/api/v1/parties_controller.rbs b/sig/api/v1/parties_controller.rbs deleted file mode 100644 index 7f8c4ef..0000000 --- a/sig/api/v1/parties_controller.rbs +++ /dev/null @@ -1,111 +0,0 @@ -module Api - module V1 - class PartiesController < Api::V1::ApiController - @parties: ActiveRecord::Relation[Party] - - MAX_CHARACTERS: Integer - MAX_SUMMONS: Integer - MAX_WEAPONS: Integer - DEFAULT_MIN_CHARACTERS: Integer - DEFAULT_MIN_SUMMONS: Integer - DEFAULT_MIN_WEAPONS: Integer - DEFAULT_MAX_CLEAR_TIME: Integer - - def create: () -> void - - def show: () -> void - - def update: () -> void - - def destroy: () -> void - - def remix: () -> void - - def index: () -> void - - def favorites: () -> void - - def preview: () -> void - - def preview_status: () -> void - - def regenerate_preview: () -> void - - private - - def authorize: () -> void - - def grid_table_and_object_table: (String) -> [String?, String?] - - def not_owner: () -> bool - - def schedule_preview_generation: () -> void - - def apply_filters: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] - - def apply_privacy_settings: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] - - def apply_includes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party] - - def apply_excludes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party] - - def build_filters: () -> Hash[Symbol, untyped] - - def build_date_range: () -> Range[DateTime]? - - def build_start_time: (String?) -> DateTime? - - def build_count: (String?, Integer) -> Integer - - def build_max_clear_time: (String?) -> Integer - - def build_element: (String?) -> Integer? - - def build_option: (String?) -> Integer? - - def build_query: (Hash[Symbol, untyped], bool) -> ActiveRecord::Relation[Party] - - def fetch_parties: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] - - def calculate_count: (ActiveRecord::Relation[Party]) -> Integer - - def calculate_total_pages: (Integer) -> Integer - - def paginate_parties: ( - ActiveRecord::Relation[Party], - ?page: Integer?, - ?per_page: Integer - ) -> ActiveRecord::Relation[Party] - - def excluded_characters: () -> ActiveRecord::Relation[GridCharacter]? - - def excluded_summons: () -> ActiveRecord::Relation[GridSummon]? - - def excluded_weapons: () -> ActiveRecord::Relation[GridWeapon]? - - def render_party_json: (ActiveRecord::Relation[Party]) -> void - - def privacy: (?favorites: bool) -> String? - - def user_quality: () -> String? - - def name_quality: () -> String? - - def original: () -> String? - - def includes: (String) -> String - - def excludes: (String) -> String - - def id_to_table: (String) -> String - - def remixed_name: (String) -> String - - def set_from_slug: () -> void - - def set: () -> void - - def party_params: () -> Hash[Symbol, untyped]? - end - end -end diff --git a/sig/aws_service.rbs b/sig/aws_service.rbs deleted file mode 100644 index 2deff53..0000000 --- a/sig/aws_service.rbs +++ /dev/null @@ -1,19 +0,0 @@ -class AwsService - class ConfigurationError < StandardError - end - - attr_reader bucket: String - attr_reader s3_client: Aws::S3::Client - - def initialize: () -> void - - def upload_stream: (IO io, String key) -> Aws::S3::Types::PutObjectOutput - - def file_exists?: (String key) -> bool - - private - - def credentials: () -> Hash[Symbol, String] - - def validate_credentials!: () -> void -end diff --git a/sig/granblue/downloaders/base_downloader.rbs b/sig/granblue/downloaders/base_downloader.rbs deleted file mode 100644 index 9174c1b..0000000 --- a/sig/granblue/downloaders/base_downloader.rbs +++ /dev/null @@ -1,53 +0,0 @@ -module Granblue - module Downloaders - class BaseDownloader - SIZES: Array[String] - - # Define allowed storage types - type storage = :local | :s3 | :both - - @id: String - @base_url: String - @test_mode: bool - @verbose: bool - @storage: storage - @aws_service: AwsService - - def initialize: (String id, ?test_mode: bool, ?verbose: bool, ?storage: storage) -> void - - def download: -> void - - private - - def process_download: (String url, String size, String path, ?last: bool) -> void - - def download_to_local: (String url, String download_uri) -> void - - def stream_to_s3: (String url, String s3_key) -> void - - def download_to_both: (String url, String download_uri, String s3_key) -> void - - def should_download?: (String local_path, String s3_key) -> bool - - def ensure_directories_exist: -> void - - def store_locally?: -> bool - - def download_path: (String size) -> String - - def build_s3_key: (String size, String filename) -> String - - def log_info: (String message) -> void - - def download_elemental_image: (String url, String size, String path, String filename) -> void - - def object_type: -> String - - def base_url: -> String - - def directory_for_size: (String size) -> String - - def build_url: (String size) -> String - end - end -end diff --git a/sig/granblue/downloaders/character_downloader.rbs b/sig/granblue/downloaders/character_downloader.rbs deleted file mode 100644 index b6dc2b7..0000000 --- a/sig/granblue/downloaders/character_downloader.rbs +++ /dev/null @@ -1,28 +0,0 @@ -module Granblue - module Downloaders - class CharacterDownloader < BaseDownloader - private - - def download_variants: (Character character) -> void - - def download_variant: (String variant_id) -> void - - def build_variant_url: (String variant_id, String size) -> String - - def object_type: -> String - - def base_url: -> String - - def directory_for_size: (String size) -> String - - private - - @id: String - @base_url: String - @test_mode: bool - @verbose: bool - @storage: Symbol - @aws_service: AwsService - end - end -end diff --git a/sig/granblue/downloaders/download_manager.rbs b/sig/granblue/downloaders/download_manager.rbs deleted file mode 100644 index 52d92c2..0000000 --- a/sig/granblue/downloaders/download_manager.rbs +++ /dev/null @@ -1,15 +0,0 @@ -module Granblue - module Downloaders - class DownloadManager - def self.download_for_object: ( - String type, - String granblue_id, - ?test_mode: bool, - ?verbose: bool, - ?storage: Symbol - ) -> void - - private - end - end -end diff --git a/sig/granblue/downloaders/summon_downloader.rbs b/sig/granblue/downloaders/summon_downloader.rbs deleted file mode 100644 index 7cac4eb..0000000 --- a/sig/granblue/downloaders/summon_downloader.rbs +++ /dev/null @@ -1,30 +0,0 @@ -module Granblue - module Downloaders - class SummonDownloader < BaseDownloader - def download: -> void - - private - - def download_variants: (Summon summon) -> void - - def download_variant: (String variant_id) -> void - - def build_variant_url: (String variant_id, String size) -> String - - def object_type: -> String - - def base_url: -> String - - def directory_for_size: (String size) -> String - - private - - @id: String - @base_url: String - @test_mode: bool - @verbose: bool - @storage: Symbol - @aws_service: AwsService - end - end -end diff --git a/sig/granblue/downloaders/weapon_downloader.rbs b/sig/granblue/downloaders/weapon_downloader.rbs deleted file mode 100644 index 878deeb..0000000 --- a/sig/granblue/downloaders/weapon_downloader.rbs +++ /dev/null @@ -1,48 +0,0 @@ -module Granblue - module Downloaders - class WeaponDownloader < BaseDownloader - def download: -> void - - private - - def download_variants: (Weapon weapon) -> void - - def download_variant: (String variant_id) -> void - - def build_variant_url: (String variant_id, String size) -> String - - def object_type: -> String - - def base_url: -> String - - def directory_for_size: (String size) -> String - - def build_url_for_id: (String id, String size) -> String - - # Track progress of elemental weapon downloads - def progress_reporter: (count: Integer, total: Integer, result: String, ?bar_len: Integer) -> void - - private - - @id: String - @base_url: String - @test_mode: bool - @verbose: bool - @storage: Symbol - @aws_service: AwsService - end - - # Special downloader for handling elemental weapon variants - class ElementalWeaponDownloader < WeaponDownloader - SUFFIXES: Array[Integer] - - def initialize: (Integer id_base) -> void - - def download: -> void - - private - - @id_base: Integer - end - end -end diff --git a/sig/granblue/importers/base_importer.rbs b/sig/granblue/importers/base_importer.rbs deleted file mode 100644 index 3627c17..0000000 --- a/sig/granblue/importers/base_importer.rbs +++ /dev/null @@ -1,80 +0,0 @@ -module Granblue - module Importers - class BaseImporter - attr_reader new_records: Hash[String, Array[Hash[Symbol, untyped]]] - attr_reader updated_records: Hash[String, Array[Hash[Symbol, untyped]]] - - def initialize: ( - String file_path, - ?test_mode: bool, - ?verbose: bool, - ?logger: untyped - ) -> void - - def import: -> Hash[Symbol, Hash[String, Array[Hash[Symbol, untyped]]]] - - def simulate_import: -> Hash[Symbol, Hash[String, Array[Hash[Symbol, untyped]]]] - - private - - def import_row: (CSV::Row row) -> void - - def find_or_create_record: (Hash[Symbol, untyped] attributes) -> [untyped, bool]? - - def simulate_create: ( - Hash[Symbol, untyped] attributes, - Hash[String, Array[Hash[Symbol, untyped]]] simulated_new, - String type - ) -> void - - def simulate_update: ( - untyped existing_record, - Hash[Symbol, untyped] attributes, - Hash[String, Array[Hash[Symbol, untyped]]] simulated_updated, - String type - ) -> void - - def validate_required_attributes: (Hash[Symbol, untyped] attributes) -> void - - def validate_update_attributes: (Hash[Symbol, untyped] update_attributes) -> void - - def validate_record: (untyped record) -> void - - def track_record: ([untyped, bool] result) -> void - - def format_attributes: (Hash[Symbol, untyped] attributes) -> String - - def log_test_update: (untyped record, Hash[Symbol, untyped] attributes) -> void - - def log_test_creation: (Hash[Symbol, untyped] attributes) -> void - - def log_new_record: (untyped record) -> void - - def log_updated_record: (untyped record) -> void - - def parse_value: (String? value) -> String? - - def parse_integer: (String? value) -> Integer? - - def parse_float: (String? value) -> Float? - - def parse_boolean: (String? value) -> bool? - - def parse_date: (String? date_str) -> Date? - - def parse_array: (String? array_str) -> Array[String] - - def parse_integer_array: (String? array_str) -> Array[Integer] - - def model_class: -> singleton(ActiveRecord::Base) - - def build_attributes: (CSV::Row row) -> Hash[Symbol, untyped] - - def handle_error: (StandardError error) -> void - - def format_validation_error: (ActiveRecord::RecordInvalid error) -> String - - def format_standard_error: (StandardError error) -> String - end - end -end diff --git a/sig/granblue/importers/import_error.rbs b/sig/granblue/importers/import_error.rbs deleted file mode 100644 index 50f6fc1..0000000 --- a/sig/granblue/importers/import_error.rbs +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Granblue - module Importers - class ImportError - attr_reader file_name: String - attr_reader details: String - - def initialize: (file_name: String, details: String) -> void - - private - - def build_message: () -> String - end - - def format_attributes: ( - attributes: Hash[Symbol, String | Integer | Float | Boolean | Array[untyped] | nil] - ) -> String - end -end diff --git a/sig/granblue/transformers/base_transformer.rbs b/sig/granblue/transformers/base_transformer.rbs deleted file mode 100644 index 6eca1ba..0000000 --- a/sig/granblue/transformers/base_transformer.rbs +++ /dev/null @@ -1,31 +0,0 @@ -module Granblue - module Transformers - class TransformerError < StandardError - attr_reader details: untyped - - def initialize: (String message, ?untyped details) -> void - end - - class BaseTransformer - ELEMENT_MAPPING: Hash[Integer, Integer?] - - @data: untyped - @options: Hash[Symbol, untyped] - @language: String - - attr_reader data: untyped - attr_reader options: Hash[Symbol, untyped] - attr_reader language: String - - def initialize: (untyped data, ?Hash[Symbol, untyped] options) -> void - - def transform: -> untyped - - def validate_data: -> bool - - def get_master_param: (Hash[String, untyped] obj) -> [Hash[String, untyped]?, Hash[String, untyped]?] - - def log_debug: (String message) -> void - end - end -end diff --git a/sig/granblue/transformers/summon_transformer.rbs b/sig/granblue/transformers/summon_transformer.rbs deleted file mode 100644 index b6199ea..0000000 --- a/sig/granblue/transformers/summon_transformer.rbs +++ /dev/null @@ -1,17 +0,0 @@ -module Granblue - module Transformers - class SummonTransformer < BaseTransformer - TRANSCENDENCE_LEVELS: Array[Integer] - - @quick_summon_id: String? - - def initialize: (untyped data, ?String? quick_summon_id, ?Hash[Symbol, untyped] options) -> void - - def transform: -> Array[Hash[Symbol, untyped]] - - private - - def calculate_transcendence_level: (Integer? level) -> Integer - end - end -end diff --git a/sig/granblue/transformers/weapon_transformer.rbs b/sig/granblue/transformers/weapon_transformer.rbs deleted file mode 100644 index b4402d3..0000000 --- a/sig/granblue/transformers/weapon_transformer.rbs +++ /dev/null @@ -1,27 +0,0 @@ -module Granblue - module Transformers - class WeaponTransformer < BaseTransformer - # Constants for level calculations - UNCAP_LEVELS: Array[Integer] - TRANSCENDENCE_LEVELS: Array[Integer] - MULTIELEMENT_SERIES: Array[Integer] - - # Implements abstract method from BaseTransformer - def transform: -> Array[Hash[Symbol, untyped]] - - private - - def transform_base_attributes: (Hash[String, untyped] master, Hash[String, untyped] param) -> Hash[Symbol, untyped] - - def transform_awakening: (Hash[String, untyped] param) -> Hash[Symbol, Hash[Symbol, untyped]] - - def transform_ax_skills: (Hash[String, untyped] param) -> Hash[Symbol, Array[Hash[Symbol, untyped]]] - - def transform_weapon_keys: (Hash[String, untyped] weapon_data) -> Hash[Symbol, Array[String]] - - def calculate_uncap_level: (Integer? level) -> Integer - - def calculate_transcendence_level: (Integer? level) -> Integer - end - end -end diff --git a/sig/preview_service/canvas.rbs b/sig/preview_service/canvas.rbs deleted file mode 100644 index 610e066..0000000 --- a/sig/preview_service/canvas.rbs +++ /dev/null @@ -1,23 +0,0 @@ -module PreviewService - class Canvas - PREVIEW_WIDTH: Integer - PREVIEW_HEIGHT: Integer - DEFAULT_BACKGROUND_COLOR: String - - def create_blank_canvas: ( - ?width: Integer, - ?height: Integer, - ?color: String - ) -> Tempfile - - def add_text: ( - MiniMagick::Image image, - String text, - ?x: Integer, - ?y: Integer, - ?size: String, - ?color: String, - ?font: String - ) -> MiniMagick::Image - end -end diff --git a/sig/preview_service/coordinator.rbs b/sig/preview_service/coordinator.rbs deleted file mode 100644 index 94ea739..0000000 --- a/sig/preview_service/coordinator.rbs +++ /dev/null @@ -1,78 +0,0 @@ -# sig/services/preview_service/coordinator.rbs - -module PreviewService - class Coordinator - PREVIEW_FOLDER: String - PREVIEW_WIDTH: Integer - PREVIEW_HEIGHT: Integer - PREVIEW_EXPIRY: ActiveSupport::Duration - GENERATION_TIMEOUT: ActiveSupport::Duration - LOCAL_STORAGE_PATH: Pathname - - @party: Party - @image_fetcher: ImageFetcherService - @grid_service: Grid - @canvas_service: Canvas - @aws_service: AwsService - - def initialize: (party: Party) -> void - - def get_s3_object: () -> Aws::S3::Types::GetObjectOutput - - def preview_url: () -> String - - def generate_preview: () -> bool - - def force_regenerate: () -> bool - - def delete_preview: () -> void - - def should_generate?: () -> bool - - def generation_in_progress?: () -> bool - - def create_preview_image: () -> MiniMagick::Image - - private - - def setup_storage: () -> void - - def add_job_icon: (image: MiniMagick::Image, job_icon: MiniMagick::Image) -> MiniMagick::Image - - def organize_and_draw_weapons: (image: MiniMagick::Image, grid_layout: Hash[Symbol, untyped]) -> MiniMagick::Image - - def draw_mainhand_weapon: (image: MiniMagick::Image, weapon_image: MiniMagick::Image) -> MiniMagick::Image - - def save_preview: (image: MiniMagick::Image) -> void - - def upload_to_s3: (image: MiniMagick::Image) -> void - - def save_to_local_storage: (image: MiniMagick::Image) -> void - - def preview_filename: () -> String - - def local_preview_path: () -> Pathname - - def local_preview_url: () -> String - - def preview_key: () -> String - - def preview_exists?: () -> bool - - def generate_s3_url: () -> String - - def set_generation_in_progress: () -> void - - def clear_generation_in_progress: () -> void - - def schedule_generation: () -> void - - def default_preview_url: () -> String - - def delete_s3_preview: () -> void - - def delete_local_previews: () -> void - - def handle_preview_generation_error: (error: Exception) -> void - end -end diff --git a/sig/preview_service/grid.rbs b/sig/preview_service/grid.rbs deleted file mode 100644 index e9c3bff..0000000 --- a/sig/preview_service/grid.rbs +++ /dev/null @@ -1,18 +0,0 @@ -module PreviewService - class Grid - GRID_MARGIN: Integer - GRID_CELL_SIZE: Integer - GRID_START_X: Integer - GRID_START_Y: Integer - - def grid_position: (String type, Integer idx) -> { x: Integer, y: Integer } - - def draw_grid_item: ( - MiniMagick::Image image, - MiniMagick::Image item_image, - String type, - Integer idx, - ?resize_to: Integer - ) -> MiniMagick::Image - end -end diff --git a/sig/preview_service/image_fetcher_service.rbs b/sig/preview_service/image_fetcher_service.rbs deleted file mode 100644 index c544963..0000000 --- a/sig/preview_service/image_fetcher_service.rbs +++ /dev/null @@ -1,26 +0,0 @@ -module PreviewService - class ImageFetcherService - @aws_service: AwsService - @tempfiles: Array[Tempfile] - - def initialize: (AwsService aws_service) -> void - - def fetch_s3_image: (String key, ?String folder) -> MiniMagick::Image? - - def fetch_job_icon: (String job_name) -> MiniMagick::Image? - - def fetch_weapon_image: (Weapon weapon, ?mainhand: bool) -> MiniMagick::Image? - - def cleanup: -> void - - private - - def create_temp_file: -> Tempfile - - def download_from_s3: (String key, Tempfile temp_file) -> void - - def create_mini_magick_image: (Tempfile temp_file) -> MiniMagick::Image - - def handle_fetch_error: (Exception error, String key) -> nil - end -end diff --git a/spec/controllers/concerns/party_authorization_concern_spec.rb b/spec/controllers/concerns/party_authorization_concern_spec.rb new file mode 100644 index 0000000..8953489 --- /dev/null +++ b/spec/controllers/concerns/party_authorization_concern_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Dummy controller that includes the PartyAuthorizationConcern. +# This allows us to test its instance methods in isolation. +class DummyAuthorizationController < ActionController::Base + include PartyAuthorizationConcern + + attr_accessor :party, :current_user, :edit_key + + # Override render_unauthorized_response to set a flag. + def render_unauthorized_response + @_unauthorized_called = true + end + + def unauthorized_called? + @_unauthorized_called || false + end +end + +RSpec.describe DummyAuthorizationController, type: :controller do + let(:dummy_controller) { DummyAuthorizationController.new } + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:anonymous_party) { create(:party, user: nil, edit_key: 'anonkey') } + let(:owned_party) { create(:party, user: user) } + + describe '#authorize_party!' do + context 'when the party belongs to a logged in user' do + before do + dummy_controller.party = owned_party + end + + context 'and current_user matches party.user' do + before { dummy_controller.current_user = user } + it 'does not call render_unauthorized_response' do + dummy_controller.authorize_party! + expect(dummy_controller.unauthorized_called?).to be false + end + end + + context 'and current_user is missing or does not match' do + before { dummy_controller.current_user = other_user } + it 'calls render_unauthorized_response' do + dummy_controller.authorize_party! + expect(dummy_controller.unauthorized_called?).to be true + end + end + end + + context 'when the party is anonymous (no user)' do + before do + dummy_controller.party = anonymous_party + end + + context 'with a valid edit_key' do + before { dummy_controller.edit_key = 'anonkey' } + it 'does not call render_unauthorized_response' do + dummy_controller.authorize_party! + expect(dummy_controller.unauthorized_called?).to be false + end + end + + context 'with an invalid edit_key' do + before { dummy_controller.edit_key = 'wrongkey' } + it 'calls render_unauthorized_response' do + dummy_controller.authorize_party! + expect(dummy_controller.unauthorized_called?).to be true + end + end + end + end + + describe '#not_owner?' do + context 'when the party belongs to a logged in user' do + before do + dummy_controller.party = owned_party + end + + context 'and current_user matches party.user' do + before { dummy_controller.current_user = user } + it 'returns false' do + expect(dummy_controller.not_owner?).to be false + end + end + + context 'and current_user does not match party.user' do + before { dummy_controller.current_user = other_user } + it 'returns true' do + expect(dummy_controller.not_owner?).to be true + end + end + end + + context 'when the party is anonymous' do + before do + dummy_controller.party = anonymous_party + end + + context 'and the provided edit_key matches' do + before { dummy_controller.edit_key = 'anonkey' } + it 'returns false' do + expect(dummy_controller.not_owner?).to be false + end + end + + context 'and the provided edit_key does not match' do + before { dummy_controller.edit_key = 'wrongkey' } + it 'returns true' do + expect(dummy_controller.not_owner?).to be true + end + end + end + end + + # Debug block: prints debug info if an example fails. + 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 diff --git a/spec/controllers/concerns/party_querying_concern_spec.rb b/spec/controllers/concerns/party_querying_concern_spec.rb new file mode 100644 index 0000000..40329ee --- /dev/null +++ b/spec/controllers/concerns/party_querying_concern_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Dummy class including PartyQueryingConcern so that we can test its methods in isolation. +class DummyQueryClass + include PartyQueryingConcern + + # Define a setter and getter for current_user so that the concern can call it. + attr_accessor :current_user + + # Provide a basic params method for testing. + attr_writer :params + + def params + @params ||= {} + end +end + +RSpec.describe DummyQueryClass do + let(:dummy) { DummyQueryClass.new } + + describe '#build_filters' do + context 'when parameters are provided' do + before do + dummy.params.merge!({ + element: '3', + raid: 'raid_id_123', + recency: '3600', + full_auto: '1', + auto_guard: '0', + charge_attack: '1', + characters_count: '4', + summons_count: '3', + weapons_count: '6' + }) + end + + it 'builds a hash with converted values and a date range for created_at' do + filters = dummy.build_filters + expect(filters[:element]).to eq(3) + expect(filters[:raid_id]).to eq('raid_id_123') + expect(filters[:created_at]).to be_a(Range) + expect(filters[:full_auto]).to eq(1) + expect(filters[:auto_guard]).to eq(0) + expect(filters[:charge_attack]).to eq(1) + # For object count ranges, we expect a Range. + expect(filters[:characters_count]).to be_a(Range) + expect(filters[:summons_count]).to be_a(Range) + expect(filters[:weapons_count]).to be_a(Range) + end + end + + context 'when no parameters are provided' do + before { dummy.params = {} } + it 'returns the default quality filters' do + filters = dummy.build_filters + expect(filters).to include( + characters_count: (PartyConstants::DEFAULT_MIN_CHARACTERS..PartyConstants::MAX_CHARACTERS), + summons_count: (PartyConstants::DEFAULT_MIN_SUMMONS..PartyConstants::MAX_SUMMONS), + weapons_count: (PartyConstants::DEFAULT_MIN_WEAPONS..PartyConstants::MAX_WEAPONS) + ) + end + end + end + + describe '#build_date_range' do + context 'with a recency parameter' do + before { dummy.params = { recency: '7200' } } + it 'returns a valid date range' do + date_range = dummy.build_date_range + expect(date_range).to be_a(Range) + expect(date_range.begin).to be <= DateTime.current + expect(date_range.end).to be >= DateTime.current - 2.hours + end + end + + context 'without a recency parameter' do + before { dummy.params = {} } + it 'returns nil' do + expect(dummy.build_date_range).to be_nil + end + end + end + + describe '#build_count' do + it 'returns the default value when blank' do + expect(dummy.build_count('', 3)).to eq(3) + end + + it 'converts string values to integer' do + expect(dummy.build_count('5', 3)).to eq(5) + end + end + + describe '#build_option' do + it 'returns nil for blank or -1 values' do + expect(dummy.build_option('')).to be_nil + expect(dummy.build_option('-1')).to be_nil + end + + it 'returns the integer value for valid input' do + expect(dummy.build_option('2')).to eq(2) + end + end + + describe '#grid_table_and_object_table' do + it 'maps id starting with "3" to grid_characters and characters' do + tables = dummy.grid_table_and_object_table('300000') + expect(tables).to eq(%w[grid_characters characters]) + end + + it 'maps id starting with "2" to grid_summons and summons' do + tables = dummy.grid_table_and_object_table('200000') + expect(tables).to eq(%w[grid_summons summons]) + end + + it 'maps id starting with "1" to grid_weapons and weapons' do + tables = dummy.grid_table_and_object_table('100000') + expect(tables).to eq(%w[grid_weapons weapons]) + end + + it 'returns [nil, nil] for an unknown prefix' do + tables = dummy.grid_table_and_object_table('900000') + expect(tables).to eq([nil, nil]) + end + end + + describe '#remixed_name' do + context 'when current_user is present' do + let(:user) { build(:user, language: 'en') } + before { dummy.instance_variable_set(:@current_user, user) } + it 'returns a remix name in English' do + expect(dummy.remixed_name('Original Party')).to eq('Remix of Original Party') + end + + context 'when user language is Japanese' do + let(:user) { build(:user, language: 'ja') } + before { dummy.instance_variable_set(:@current_user, user) } + it 'returns a remix name in Japanese' do + expect(dummy.remixed_name('オリジナル')).to eq('オリジナルのリミックス') + end + end + end + + context 'when current_user is nil' do + before { dummy.instance_variable_set(:@current_user, nil) } + it 'returns a remix name in English by default' do + expect(dummy.remixed_name('Original Party')).to eq('Remix of Original Party') + end + end + end + + # Debug block: prints debugging information if an example fails. + 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 diff --git a/spec/factories/favorites.rb b/spec/factories/favorites.rb new file mode 100644 index 0000000..101917b --- /dev/null +++ b/spec/factories/favorites.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# +# Factory for the Favorite model. This factory sets up the associations to User and Party, +# which are required as per the model definition. +# +FactoryBot.define do + factory :favorite do + association :user + association :party + end +end diff --git a/spec/factories/grid_characters.rb b/spec/factories/grid_characters.rb new file mode 100644 index 0000000..17be204 --- /dev/null +++ b/spec/factories/grid_characters.rb @@ -0,0 +1,20 @@ +FactoryBot.define do + factory :grid_character do + association :party + # Use the canonical (seeded) Character record. + # Make sure your CSV canonical data (loaded via canonical.rb) includes a Character with the specified granblue_id. + character { Character.find_by!(granblue_id: '3040087000') } + position { 0 } + uncap_level { 3 } + transcendence_step { 0 } + # Virtual attributes default to nil. + new_rings { nil } + new_awakening { nil } + # JSON columns for ring data are set to default hashes. + ring1 { { 'modifier' => nil, 'strength' => nil } } + ring2 { { 'modifier' => nil, 'strength' => nil } } + ring3 { { 'modifier' => nil, 'strength' => nil } } + ring4 { { 'modifier' => nil, 'strength' => nil } } + earring { { 'modifier' => nil, 'strength' => nil } } + end +end diff --git a/spec/factories/grid_summons.rb b/spec/factories/grid_summons.rb new file mode 100644 index 0000000..7a49d24 --- /dev/null +++ b/spec/factories/grid_summons.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :grid_summon do + association :party + # Use the canonical (seeded) Summon record. + # Make sure your CSV canonical data (loaded via canonical.rb) includes a Summon with the specified granblue_id. + summon { Summon.find_by!(granblue_id: '2040433000') } + position { 1 } + uncap_level { 3 } + transcendence_step { 0 } + main { false } + friend { false } + quick_summon { false } + end +end diff --git a/spec/factories/grid_weapons.rb b/spec/factories/grid_weapons.rb index a181f98..765b70c 100644 --- a/spec/factories/grid_weapons.rb +++ b/spec/factories/grid_weapons.rb @@ -1,5 +1,17 @@ +# frozen_string_literal: true + FactoryBot.define do factory :grid_weapon do - + # Associations: assumes that factories for :party and :weapon are defined. + association :party + association :weapon + + # Default attributes + position { 0 } + uncap_level { 3 } + transcendence_step { 0 } + mainhand { false } + + # Optional associations for weapon keys and awakening are left as nil by default. end end diff --git a/spec/factories/parties.rb b/spec/factories/parties.rb index 03627dd..5480b4d 100644 --- a/spec/factories/parties.rb +++ b/spec/factories/parties.rb @@ -1,5 +1,21 @@ +# frozen_string_literal: true + FactoryBot.define do factory :party do - + association :user + + # Use a sequence for unique party names (optional). + sequence(:name) { |n| "Party #{n}" } + description { Faker::Lorem.sentence } + extra { false } + full_auto { false } + auto_guard { false } + charge_attack { true } + clear_time { 0 } + button_count { 0 } + chain_count { 0 } + turn_count { 0 } + visibility { 1 } + # Note: Shortcode and edit_key will be auto-generated via before_create callbacks. end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 628434c..f65cc8f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -1,5 +1,16 @@ FactoryBot.define do factory :user do - + email { Faker::Internet.email } + password { "password" } + password_confirmation { "password" } + username { Faker::Internet.username(specifier: 5..8) } + granblue_id { Faker::Number.number(digits: 4) } + picture { "gran" } + language { ["en", "ja"].sample } + private { Faker::Boolean.boolean } + element { ["water", "fire", "wind", "earth", "light", "dark"].sample } + gender { Faker::Number.between(from: 0, to: 1) } + theme { ["system", "dark", "light"].sample } + role { Faker::Number.between(from: 1, to: 3) } end end diff --git a/spec/models/grid_characters_spec.rb b/spec/models/grid_characters_spec.rb new file mode 100644 index 0000000..efbdf82 --- /dev/null +++ b/spec/models/grid_characters_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true +# spec/models/grid_character_spec.rb +# +# This spec verifies the GridCharacter model’s associations, validations, +# and callbacks. It uses FactoryBot for object creation, shoulda-matchers +# for association/validation shortcuts, and a custom matcher (have_error_on) +# for checking that error messages include specific phrases. +# +# In this version we use canonical data loaded from CSV (via our CSV loader) +# rather than generating new Character and Awakening records. +# +require 'rails_helper' + +RSpec.describe GridCharacter, type: :model do + # Association tests using shoulda-matchers. + it { is_expected.to belong_to(:character) } + it { is_expected.to belong_to(:party) } + it { is_expected.to belong_to(:awakening).optional } + + # Use the canonical "Balanced" awakening already loaded from CSV. + before(:all) do + @balanced_awakening = Awakening.find_by!(slug: 'character-balanced') + end + + # Use canonical records loaded from CSV for our character. + let(:party) { create(:party) } + let(:character) do + # Assume canonical test data has been loaded. + Character.find_by!(granblue_id: '3040087000') + end + + let(:valid_attributes) do + { + party: party, + character: character, + position: 0, + uncap_level: 3, + transcendence_step: 0 + } + end + + describe 'Validations and Associations' do + context 'with valid attributes' do + subject { build(:grid_character, valid_attributes) } + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'without a party' do + subject { build(:grid_character, valid_attributes.merge(party: nil)) } + it 'is invalid' do + subject.valid? + expect(subject.errors[:party]).to include("can't be blank") + end + end + end + + describe 'Callbacks' do + context 'before_validation :apply_new_rings' do + it 'sets the ring attributes when new_rings is provided' do + grid_char = build( + :grid_character, + valid_attributes.merge(new_rings: [ + { 'modifier' => '1', 'strength' => 300 }, + { 'modifier' => '2', 'strength' => 150 } + ]) + ) + grid_char.valid? # triggers the before_validation callback + expect(grid_char.ring1).to eq({ 'modifier' => '1', 'strength' => 300 }) + expect(grid_char.ring2).to eq({ 'modifier' => '2', 'strength' => 150 }) + # The rings array is padded to have exactly four entries. + expect(grid_char.ring3).to eq({ 'modifier' => nil, 'strength' => nil }) + expect(grid_char.ring4).to eq({ 'modifier' => nil, 'strength' => nil }) + end + end + + context 'before_validation :apply_new_awakening' do + it 'sets awakening_id and awakening_level when new_awakening is provided using a canonical awakening' do + # Use an existing awakening from the CSV data. + canonical_awakening = Awakening.find_by!(slug: 'character-def') + new_awakening = { id: canonical_awakening.id, level: '5' } + grid_char = build(:grid_character, valid_attributes.merge(new_awakening: new_awakening)) + grid_char.valid? + expect(grid_char.awakening_id).to eq(canonical_awakening.id) + expect(grid_char.awakening_level).to eq(5) + end + end + + context 'before_save :add_awakening' do + it 'sets the awakening to the balanced canonical awakening if none is provided' do + grid_char = build(:grid_character, valid_attributes.merge(awakening: nil)) + grid_char.save! + expect(grid_char.awakening).to eq(@balanced_awakening) + end + + it 'does not override an existing awakening' do + existing_awakening = Awakening.find_by!(slug: 'character-def') + grid_char = build(:grid_character, valid_attributes.merge(awakening: existing_awakening)) + grid_char.save! + expect(grid_char.awakening).to eq(existing_awakening) + end + end + end + + describe 'Update Validations (on :update)' do + before do + # Persist a valid GridCharacter record. + @grid_char = create(:grid_character, valid_attributes) + end + + context 'validate_awakening_level' do + it 'adds an error if awakening_level is below 1' do + @grid_char.awakening_level = 0 + @grid_char.valid?(:update) + expect(@grid_char.errors[:awakening]).to include('awakening level too low') + end + + it 'adds an error if awakening_level is above 9' do + @grid_char.awakening_level = 10 + @grid_char.valid?(:update) + expect(@grid_char.errors[:awakening]).to include('awakening level too high') + end + end + + context 'transcendence validation' do + it 'adds an error if transcendence_step is positive but character.ulb is false' do + @grid_char.character.update!(ulb: false) + @grid_char.transcendence_step = 1 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('character has no transcendence') + end + + it 'adds an error if transcendence_step is greater than 5 when character.ulb is true' do + @grid_char.character.update!(ulb: true) + @grid_char.transcendence_step = 6 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too high') + end + + it 'adds an error if transcendence_step is negative when character.ulb is true' do + @grid_char.character.update!(ulb: true) + @grid_char.transcendence_step = -1 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too low') + end + end + + context 'over_mastery_attack_matches_hp validation' do + it 'adds an error if ring1 and ring2 values are inconsistent' do + @grid_char.ring1 = { modifier: '1', strength: 300 } + # Expected: ring2 strength should be half of 300 (i.e. 150) + @grid_char.ring2 = { modifier: '2', strength: 100 } + @grid_char.valid?(:update) + expect(@grid_char.errors[:over_mastery]).to include('over mastery attack and hp values do not match') + end + + it 'is valid if ring2 strength equals half of ring1 strength' do + @grid_char.ring1 = { modifier: '1', strength: 300 } + @grid_char.ring2 = { modifier: '2', strength: 150 } + @grid_char.valid?(:update) + expect(@grid_char.errors[:over_mastery]).to be_empty + end + end + end +end diff --git a/spec/models/grid_summons_spec.rb b/spec/models/grid_summons_spec.rb new file mode 100644 index 0000000..a16a2cf --- /dev/null +++ b/spec/models/grid_summons_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Define a dummy GridSummonBlueprint if it is not already defined. +class GridSummonBlueprint; end unless defined?(GridSummonBlueprint) + +RSpec.describe GridSummon, type: :model do + describe 'associations' do + it 'belongs to a party' do + association = described_class.reflect_on_association(:party) + expect(association).not_to be_nil + expect(association.macro).to eq(:belongs_to) + end + + it 'belongs to a summon' do + association = described_class.reflect_on_association(:summon) + expect(association).not_to be_nil + expect(association.macro).to eq(:belongs_to) + end + end + + describe 'validations' do + let(:party) { create(:party) } + let(:default_summon) { Summon.find_by!(granblue_id: '2040433000') } + + context 'with valid attributes' do + subject do + build(:grid_summon, + party: party, + summon: default_summon, + position: 1, + uncap_level: 3, + transcendence_step: 0, + main: false, + friend: false, + quick_summon: false) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'with missing required attributes' do + it 'is invalid without a position' do + grid_summon = build(:grid_summon, + party: party, + summon: default_summon, + position: nil, + uncap_level: 3, + transcendence_step: 0) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:position].join).to match(/can't be blank/) + end + + it 'is invalid without a party' do + grid_summon = build(:grid_summon, + party: nil, + summon: default_summon, + position: 1, + uncap_level: 3, + transcendence_step: 0) + grid_summon.validate + expect(grid_summon.errors[:party].join).to match(/must exist|can't be blank/) + end + + it 'is invalid without a summon' do + grid_summon = build(:grid_summon, + party: party, + summon: nil, + position: 1, + uncap_level: 3, + transcendence_step: 0) + expect { grid_summon.valid? }.to raise_error(NoMethodError) + end + end + + context 'with non-numeric values' do + it 'is invalid when uncap_level is non-numeric' do + grid_summon = build(:grid_summon, + party: party, + summon: default_summon, + position: 1, + uncap_level: 'three', + transcendence_step: 0) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:uncap_level]).not_to be_empty + end + + it 'is invalid when transcendence_step is non-numeric' do + grid_summon = build(:grid_summon, + party: party, + summon: default_summon, + position: 1, + uncap_level: 3, + transcendence_step: 'one') + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:transcendence_step]).not_to be_empty + end + end + + context 'custom validations based on Summon flags' do + context 'when the summon does not have FLB flag' do + let(:summon_without_flb) { default_summon.tap { |s| s.flb = false } } + + it 'is invalid if uncap_level is greater than 3' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_flb, + position: 1, + uncap_level: 4, + transcendence_step: 0) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 3/) + end + + it 'is valid if uncap_level is 3 or less' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_flb, + position: 1, + uncap_level: 3, + transcendence_step: 0) + expect(grid_summon).to be_valid + end + end + + context 'when the summon does not have ULB flag' do + let(:summon_without_ulb) do + default_summon.tap do |s| + s.ulb = false + s.flb = true + end + end + + it 'is invalid if uncap_level is greater than 4' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_ulb, + position: 1, + uncap_level: 5, + transcendence_step: 0) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 4/) + end + + it 'is valid if uncap_level is 4 or less' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_ulb, + position: 1, + uncap_level: 4, + transcendence_step: 0) + expect(grid_summon).to be_valid + end + end + + context 'when the summon does not have transcendence flag' do + let(:summon_without_transcendence) do + # Ensure FLB and ULB are true so that only the transcendence rule applies. + default_summon.tap do |s| + s.transcendence = false + s.flb = true + s.ulb = true + end + end + + it 'is invalid if uncap_level is greater than 5' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_transcendence, + position: 1, + uncap_level: 6, + transcendence_step: 0) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 5/) + end + + it 'is invalid if transcendence_step is greater than 0' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_transcendence, + position: 1, + uncap_level: 5, + transcendence_step: 1) + expect(grid_summon).not_to be_valid + expect(grid_summon.errors[:transcendence_step].join).to match(/must be 0/) + end + + it 'is valid if uncap_level is 5 or less and transcendence_step is 0' do + grid_summon = build(:grid_summon, + party: party, + summon: summon_without_transcendence, + position: 1, + uncap_level: 5, + transcendence_step: 0) + expect(grid_summon).to be_valid + end + end + end + end + + describe 'default values' do + let(:party) { create(:party) } + let(:summon) { Summon.find_by!(granblue_id: '2040433000') } + subject do + build(:grid_summon, + party: party, + summon: summon, + position: 1, + uncap_level: 3, + transcendence_step: 0) + end + + it 'defaults quick_summon to false' do + expect(subject.quick_summon).to be_falsey + end + + it 'defaults main to false' do + expect(subject.main).to be_falsey + end + + it 'defaults friend to false' do + expect(subject.friend).to be_falsey + end + end + + describe '#blueprint' do + it 'returns the GridSummonBlueprint constant' do + grid_summon = build(:grid_summon) + expect(grid_summon.blueprint).to eq(GridSummonBlueprint) + end + end +end diff --git a/spec/models/grid_weapon_spec.rb b/spec/models/grid_weapon_spec.rb index 2c8846e..9f73265 100644 --- a/spec/models/grid_weapon_spec.rb +++ b/spec/models/grid_weapon_spec.rb @@ -1,5 +1,131 @@ +# frozen_string_literal: true + require 'rails_helper' +# Define a dummy GridWeaponBlueprint if it is not already defined. +class GridWeaponBlueprint; end unless defined?(GridWeaponBlueprint) + RSpec.describe GridWeapon, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { is_expected.to belong_to(:weapon) } + it { is_expected.to belong_to(:party) } + it { is_expected.to belong_to(:weapon_key1).optional } + it { is_expected.to belong_to(:weapon_key2).optional } + it { is_expected.to belong_to(:weapon_key3).optional } + it { is_expected.to belong_to(:weapon_key4).optional } + it { is_expected.to belong_to(:awakening).optional } + + # Setup common test objects using FactoryBot. + let(:party) { create(:party) } + let(:weapon) { create(:weapon, limit: false, series: 5) } # a non-limited weapon with series 5 + let(:grid_weapon) do + build(:grid_weapon, + party: party, + weapon: weapon, + position: 0, + uncap_level: 3, + transcendence_step: 0) + end + + describe 'Validations' do + context 'Presence validations' do + it 'requires a party' do + grid_weapon.party = nil + grid_weapon.validate + error_message = grid_weapon.errors[:party].join + expect(error_message).to include('must exist') + end + end + + context 'Custom validations' do + describe '#compatible_with_position' do + context 'when position is within extra positions [9, 10, 11]' do + before { grid_weapon.position = 9 } + + context 'and weapon series is NOT in allowed extra series' do + before { weapon.series = 5 } # Allowed extra series are [11, 16, 17, 28, 29, 32, 34] + it 'adds an error on :series' do + grid_weapon.validate + expect(grid_weapon.errors[:series]).to include('must be compatible with position') + end + end + + context 'and weapon series is in allowed extra series' do + before { weapon.series = 11 } + it 'is valid with respect to position compatibility' do + grid_weapon.validate + expect(grid_weapon.errors[:series]).to be_empty + end + end + end + + context 'when position is not in extra positions' do + before { grid_weapon.position = 2 } + it 'does not add an error on :series' do + grid_weapon.validate + expect(grid_weapon.errors[:series]).to be_empty + end + end + end + + describe '#no_conflicts' do + context 'when there is a conflicting grid weapon in the party' do + before do + # Create a limited weapon that will trigger conflict checking. + limited_weapon = create(:weapon, limit: true, series: 7) + # Create an existing grid weapon in the party using that limited weapon. + create(:grid_weapon, party: party, weapon: limited_weapon, position: 1) + # Set up grid_weapon to use the same limited weapon in a different position. + grid_weapon.weapon = limited_weapon + grid_weapon.position = 2 + end + + it 'adds an error on :series about conflicts' do + grid_weapon.validate + expect(grid_weapon.errors[:series]).to include('must not conflict with existing weapons') + end + end + + context 'when there is no conflicting grid weapon' do + it 'has no conflict errors' do + grid_weapon.validate + expect(grid_weapon.errors[:series]).to be_empty + end + end + end + end + end + + describe 'Callbacks' do + context 'before_save :mainhand?' do + it 'sets mainhand to true if position is -1' do + grid_weapon.position = -1 + grid_weapon.save! + expect(grid_weapon.mainhand).to be true + end + + it 'sets mainhand to false if position is not -1' do + grid_weapon.position = 0 + grid_weapon.save! + expect(grid_weapon.mainhand).to be false + end + end + end + + describe '#weapon_keys' do + it 'returns an array of associated weapon keys, omitting nils' do + # Create two dummy weapon keys using the factory. + weapon_key1 = create(:weapon_key) + weapon_key2 = create(:weapon_key) + grid_weapon.weapon_key1 = weapon_key1 + grid_weapon.weapon_key2 = weapon_key2 + grid_weapon.weapon_key3 = nil + expect(grid_weapon.weapon_keys).to match_array([weapon_key1, weapon_key2]) + end + end + + describe '#blueprint' do + it 'returns the GridWeaponBlueprint constant' do + expect(grid_weapon.blueprint).to eq(GridWeaponBlueprint) + end + end end diff --git a/spec/models/party_spec.rb b/spec/models/party_spec.rb index bb1a85b..e13d350 100644 --- a/spec/models/party_spec.rb +++ b/spec/models/party_spec.rb @@ -1,5 +1,416 @@ require 'rails_helper' +include ActiveJob::TestHelper RSpec.describe Party, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'validations' do + context 'for element' do + it 'is valid when element is nil' do + party = build(:party, element: nil) + expect(party).to be_valid + end + + it 'is valid when element is one of the allowed values' do + GranblueEnums::ELEMENTS.values.each do |value| + party = build(:party, element: value) + expect(party).to be_valid, "expected element #{value} to be valid" + end + end + + it 'is invalid when element is not one of the allowed values' do + party = build(:party, element: 7) + expect(party).not_to be_valid + expect(party.errors[:element]).to include(/must be one of/) + end + + it 'is invalid when element is not an integer' do + party = build(:party, element: 'fire') + expect(party).not_to be_valid + expect(party.errors[:element]).to include(/is not a number/) + end + end + + context 'for master_level' do + it { should validate_numericality_of(:master_level).only_integer.allow_nil } + it 'is invalid when master_level is non-integer' do + party = build(:party, master_level: 'high') + expect(party).not_to be_valid + expect(party.errors[:master_level]).to include(/is not a number/) + end + end + + context 'for clear_time' do + it { should validate_numericality_of(:clear_time).only_integer } + it 'is invalid when clear_time is non-integer' do + party = build(:party, clear_time: 'fast') + expect(party).not_to be_valid + expect(party.errors[:clear_time]).to include(/is not a number/) + end + end + + context 'for button_count' do + it { should validate_numericality_of(:button_count).only_integer.allow_nil } + it 'is invalid when button_count is non-integer' do + party = build(:party, button_count: 'ten') + expect(party).not_to be_valid + expect(party.errors[:button_count]).to include(/is not a number/) + end + end + + context 'for chain_count' do + it { should validate_numericality_of(:chain_count).only_integer.allow_nil } + it 'is invalid when chain_count is non-integer' do + party = build(:party, chain_count: 'two') + expect(party).not_to be_valid + expect(party.errors[:chain_count]).to include(/is not a number/) + end + end + + context 'for turn_count' do + it { should validate_numericality_of(:turn_count).only_integer.allow_nil } + it 'is invalid when turn_count is non-integer' do + party = build(:party, turn_count: 'five') + expect(party).not_to be_valid + expect(party.errors[:turn_count]).to include(/is not a number/) + end + end + + context 'for ultimate_mastery' do + it { should validate_numericality_of(:ultimate_mastery).only_integer.allow_nil } + it 'is invalid when ultimate_mastery is non-integer' do + party = build(:party, ultimate_mastery: 'max') + expect(party).not_to be_valid + expect(party.errors[:ultimate_mastery]).to include(/is not a number/) + end + end + + context 'for visibility' do + it { should validate_numericality_of(:visibility).only_integer } + it 'is valid when visibility is one of 1, 2, or 3' do + [1, 2, 3].each do |value| + party = build(:party, visibility: value) + expect(party).to be_valid, "expected visibility #{value} to be valid" + end + end + it 'is invalid when visibility is not in [1, 2, 3]' do + party = build(:party, visibility: 0) + expect(party).not_to be_valid + expect(party.errors[:visibility]).to include(/must be 1 \(Public\), 2 \(Unlisted\), or 3 \(Private\)/) + end + it 'is invalid when visibility is non-integer' do + party = build(:party, visibility: 'public') + expect(party).not_to be_valid + expect(party.errors[:visibility]).to include(/is not a number/) + end + end + + context 'for preview_state' do + # Since preview_state is now an enum, we test valid enum keys. + it 'allows valid preview_state values via enum' do + %w[pending queued in_progress generated failed].each do |state| + party = build(:party, preview_state: state) + expect(party).to be_valid, "expected preview_state #{state} to be valid" + end + end + + it 'is invalid when preview_state is non-numeric and not a valid enum key' do + expect { build(:party, preview_state: 'active') } + .to raise_error(ArgumentError, /'active' is not a valid preview_state/) + end + end + end + + describe '#is_remix' do + context 'when source_party is nil' do + it 'returns false' do + party = build(:party, source_party: nil) + expect(party.remix?).to be false + end + end + + context 'when source_party is present' do + it 'returns true' do + parent = create(:party) + remix = build(:party, source_party: parent) + expect(remix.remix?).to be true + end + end + end + + describe '#remixes' do + it 'returns all parties whose source_party_id equals the party id' do + parent = create(:party) + remix1 = create(:party, source_party: parent) + remix2 = create(:party, source_party: parent) + expect(parent.remixes.map(&:id)).to match_array([remix1.id, remix2.id]) + end + end + + describe 'Visibility helpers (#public?, #unlisted?, #private?)' do + it 'returns public? true when visibility is 1' do + party = build(:party, visibility: 1) + expect(party.public?).to be true + expect(party.unlisted?).to be false + expect(party.private?).to be false + end + + it 'returns unlisted? true when visibility is 2' do + party = build(:party, visibility: 2) + expect(party.unlisted?).to be true + expect(party.public?).to be false + expect(party.private?).to be false + end + + it 'returns private? true when visibility is 3' do + party = build(:party, visibility: 3) + expect(party.private?).to be true + expect(party.public?).to be false + expect(party.unlisted?).to be false + end + end + + describe '#is_favorited' do + let(:user) { create(:user) } + + it 'returns false if the passed user is nil' do + party = build(:party) + expect(party.favorited?(nil)).to be false + end + + it 'returns true if the party is favorited by the user' do + party = create(:party) + create(:favorite, user: user, party: party) + Rails.cache.clear + expect(party.favorited?(user)).to be true + end + + it 'returns false if the party is not favorited by the user' do + party = create(:party) + Rails.cache.clear + expect(party.favorited?(user)).to be false + end + end + + describe '#ready_for_preview?' do + it 'returns false if weapons_count is less than 1' do + party = build(:party, weapons_count: 0, characters_count: 1, summons_count: 1) + expect(party.ready_for_preview?).to be false + end + + it 'returns false if characters_count is less than 1' do + party = build(:party, weapons_count: 1, characters_count: 0, summons_count: 1) + expect(party.ready_for_preview?).to be false + end + + it 'returns false if summons_count is less than 1' do + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 0) + expect(party.ready_for_preview?).to be false + end + + it 'returns true when all counts are at least 1' do + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1) + expect(party.ready_for_preview?).to be true + end + end + + describe '#preview_expired?' do + it 'returns true if preview_generated_at is nil' do + party = build(:party, preview_generated_at: nil) + expect(party.preview_expired?).to be true + end + + it 'returns true if preview_generated_at is older than the expiry period' do + expired_time = PreviewService::Coordinator::PREVIEW_EXPIRY.ago - 1.minute + party = build(:party, preview_generated_at: expired_time) + expect(party.preview_expired?).to be true + end + + it 'returns false if preview_generated_at is recent' do + recent_time = Time.current - 1.hour + party = build(:party, preview_generated_at: recent_time) + expect(party.preview_expired?).to be false + end + end + + describe '#preview_content_changed?' do + it 'returns true if saved_changes include a preview relevant attribute' do + party = build(:party) + # Stub saved_changes so that it includes a key from preview_relevant_attributes (e.g. "name") + allow(party).to receive(:saved_changes).and_return('name' => ['Old', 'New']) + expect(party.preview_content_changed?).to be true + end + + it 'returns false if saved_changes do not include any preview relevant attributes' do + party = build(:party) + allow(party).to receive(:saved_changes).and_return('non_relevant' => ['A', 'B']) + expect(party.preview_content_changed?).to be false + end + end + + describe '#should_generate_preview?' do + context 'when ready_for_preview? is false' do + it 'returns false regardless of preview_state' do + party = build(:party, weapons_count: 0, characters_count: 1, summons_count: 1, preview_state: 'pending') + expect(party.should_generate_preview?).to be false + end + end + + context 'when preview_state is nil or pending' do + it 'returns true' do + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: nil) + expect(party.should_generate_preview?).to be true + party.preview_state = 'pending' + expect(party.should_generate_preview?).to be true + end + end + + context "when preview_state is 'failed'" do + it 'returns true if preview_generated_at is more than 5 minutes ago' do + past_time = 6.minutes.ago + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, + preview_state: 'failed', preview_generated_at: past_time) + expect(party.should_generate_preview?).to be true + end + end + + context "when preview_state is 'generated'" do + it 'returns true if preview is expired' do + expired_time = PreviewService::Coordinator::PREVIEW_EXPIRY.ago - 1.minute + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, + preview_state: 'generated', preview_generated_at: expired_time) + expect(party.should_generate_preview?).to be true + end + + it 'returns false if preview is recent and no content change is detected' do + recent_time = Time.current - 1.minute + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, + preview_state: 'generated', preview_generated_at: recent_time) + allow(party).to receive(:saved_changes).and_return('non_relevant' => ['A', 'B']) + expect(party.should_generate_preview?).to be false + end + + it 'returns true if content has changed and preview_generated_at is more than 5 minutes ago' do + old_time = 6.minutes.ago + party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, + preview_state: 'generated', preview_generated_at: old_time) + allow(party).to receive(:saved_changes).and_return('name' => ['Old', 'New']) + expect(party.should_generate_preview?).to be true + end + end + end + + describe '#schedule_preview_generation' do + before(:all) do + ActiveJob::Base.queue_adapter = :test + end + + it 'enqueues a GeneratePartyPreviewJob and sets preview_state to "queued" if not already queued or in_progress' do + # Create a party normally, then force its preview_state to "pending" (the integer value) + party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1) + party.update_column(:preview_state, Party.preview_states[:pending]) + + clear_enqueued_jobs + expect { party.schedule_preview_generation } + .to have_enqueued_job(GeneratePartyPreviewJob) + .with(party.id) + party.reload + expect(party.preview_state).to eq('queued') + end + + it 'does nothing if preview_state is already "queued"' do + party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: 'queued') + clear_enqueued_jobs + expect { party.schedule_preview_generation }.not_to(change { enqueued_jobs.count }) + end + + it 'does nothing if preview_state is "in_progress"' do + party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: 'in_progress') + clear_enqueued_jobs + expect { party.schedule_preview_generation }.not_to(change { enqueued_jobs.count }) + end + end + + describe '#update_element!' do + it 'updates the party element if a main weapon (position -1) with a different element is present' do + # Create a party with element 3 (Water) initially. + party = create(:party, element: 3) + # Create a dummy weapon (using an instance double) with element 2 (Fire). + fire_weapon = instance_double('Weapon', element: 2) + # Create a dummy grid weapon with position -1 and the fire_weapon. + grid_weapon = instance_double('GridWeapon', position: -1, weapon: fire_weapon) + allow(party).to receive(:weapons).and_return([grid_weapon]) + # Expect update_column to be called with the new element. + expect(party).to receive(:update_column).with(:element, 2) + party.send(:update_element!) + end + + it 'does not update the party element if no main weapon is found' do + party = create(:party, element: 3) + allow(party).to receive(:weapons).and_return([]) + expect(party).not_to receive(:update_column) + party.send(:update_element!) + end + end + + describe '#update_extra!' do + it 'updates the party extra flag to true if any weapon is in an extra position' do + party = create(:party, extra: false) + grid_weapon = instance_double('GridWeapon', position: 9) + allow(party).to receive(:weapons).and_return([grid_weapon]) + expect(party).to receive(:update_column).with(:extra, true) + party.send(:update_extra!) + end + + it 'does not update the party extra flag if no weapon is in an extra position' do + party = create(:party, extra: false) + allow(party).to receive(:weapons).and_return([instance_double('GridWeapon', position: 0)]) + expect(party).not_to receive(:update_column) + party.send(:update_extra!) + end + end + + describe '#set_shortcode' do + it 'sets a shortcode of length 6' do + party = build(:party, shortcode: nil) + party.send(:set_shortcode) + expect(party.shortcode.length).to eq(6) + end + end + + describe '#set_edit_key' do + it 'sets edit_key for an anonymous party (when user is nil)' do + party = build(:party, user: nil, edit_key: nil) + party.send(:set_edit_key) + expect(party.edit_key).not_to be_nil + end + + it 'does not set edit_key when a user is present' do + party = build(:party, user: create(:user), edit_key: nil) + party.send(:set_edit_key) + expect(party.edit_key).to be_nil + end + end + + describe '#random_string' do + it 'returns an alphanumeric string of length 6 by default' do + party = build(:party) + str = party.send(:random_string) + expect(str.length).to eq(6) + expect(str).to match(/\A[a-zA-Z0-9]+\z/) + end + end + + describe '#preview_relevant_attributes' do + it 'returns an array of expected attribute names' do + party = build(:party) + expected = %w[name job_id element weapons_count characters_count summons_count full_auto auto_guard charge_attack clear_time] + expect(party.send(:preview_relevant_attributes)).to match_array(expected) + end + end + + # Debug block: print debug info if an example fails. + after(:each) do |example| + if example.exception + puts "\nDEBUG [Party Model Validations]: Failed example: #{example.full_description}" + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..7aae24f --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' + +# Prevent database truncation if the environment is production +abort('The Rails environment is running in production mode!') if Rails.env.production? + +# Load Rails and RSpec Rails – Rails is not loaded until after the environment is set. +require 'rspec/rails' + +# ----------------------------------------------------------------------------- +# Additional Requires: +# +# Add any additional requires below this line. For example, if you need to load +# custom libraries or support files that are not automatically required. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# Require Support Files: +# +# All files in the spec/support directory and its subdirectories (except those +# ending in _spec.rb) are automatically required here. This is useful for custom +# matchers, macros, and shared contexts. +# ----------------------------------------------------------------------------- +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } + +# ----------------------------------------------------------------------------- +# Check for Pending Migrations: +# +# This will check for any pending migrations before tests are run. +# ----------------------------------------------------------------------------- +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end + +RSpec.configure do |config| + # Disable ActiveRecord logging during tests for a cleaner test output. + ActiveRecord::Base.logger = nil if Rails.env.test? + + # ----------------------------------------------------------------------------- + # Shoulda Matchers: + # + # If you use shoulda-matchers, you can configure them here. (Make sure you have + # the shoulda-matchers gem installed and configured in your Gemfile.) + # ----------------------------------------------------------------------------- + require 'shoulda/matchers' + Shoulda::Matchers.configure do |matcher_config| + matcher_config.integrate do |with| + with.test_framework :rspec + with.library :rails + end + end + + # ----------------------------------------------------------------------------- + # FactoryBot Syntax Methods: + # + # This makes methods like create and build available without needing to prefix + # them with FactoryBot. + # ----------------------------------------------------------------------------- + config.include FactoryBot::Syntax::Methods + + # ----------------------------------------------------------------------------- + # Load canonical seed data for test environment: + # + # This ensures that your canonical CSV data is loaded before your tests run. + # ----------------------------------------------------------------------------- + config.before(:suite) do + load Rails.root.join('db', 'seed', 'canonical.rb') + end + + # ----------------------------------------------------------------------------- + # Backtrace Filtering: + # + # Filter out lines from Rails gems in backtraces for clarity. + # ----------------------------------------------------------------------------- + config.filter_rails_from_backtrace! + # You can add additional filters here if needed: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/requests/grid_characters_controller_spec.rb b/spec/requests/grid_characters_controller_spec.rb new file mode 100644 index 0000000..459a984 --- /dev/null +++ b/spec/requests/grid_characters_controller_spec.rb @@ -0,0 +1,384 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'GridCharacters API', type: :request do + let(:user) { create(:user) } + let(:party) { create(:party, user: user, edit_key: 'secret') } + 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 + + # Using canonical data from CSV for non-user-generated models. + let(:incoming_character) { Character.find_by(granblue_id: '3040036000') } + + describe 'Authorization for editing grid characters' do + context 'when the party is owned by a logged in user' do + let(:valid_params) do + { + character: { + party_id: party.id, + character_id: incoming_character.id, + position: 1, + uncap_level: 3, + transcendence_step: 0, + perpetuity: false, + rings: [{ modifier: '1', strength: '1500' }], + awakening: { id: 'character-balanced', level: 1 } + } + } + end + + it 'allows the owner to create a grid character' do + expect do + post '/api/v1/characters', params: valid_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(1) + expect(response).to have_http_status(:created) + end + + it 'allows the owner to update a grid character' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 2, + uncap_level: 3, + transcendence_step: 0) + update_params = { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + position: 2, + uncap_level: 4, + transcendence_step: 1, + rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }], + awakening: { id: 'character-attack', level: 2 } + } + } + + # Use the resource route for update (as defined by resources :grid_characters) + put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1) + end + + it 'allows the owner to update the uncap level and transcendence step' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 3, + uncap_level: 3, + transcendence_step: 0) + update_uncap_params = { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + uncap_level: 5, + transcendence_step: 1 + } + } + post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1) + end + + it 'allows the owner to resolve conflicts by replacing an existing grid character' do + # Create a conflicting grid character (same character_id) at the target position. + conflicting_character = create(:grid_character, + party: party, + character: incoming_character, + position: 4, + uncap_level: 3) + resolve_params = { + resolve: { + position: 4, + incoming: incoming_character.id, + conflicting: [conflicting_character.id] + } + } + expect do + post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(0) # one record is destroyed and one is created + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('position' => 4) + expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'allows the owner to destroy a grid character' do + grid_character = create(:grid_character, + party: party, + character: incoming_character, + position: 5, + uncap_level: 3) + # Using the custom route for destroy: DELETE '/api/v1/characters' + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) + end + end + + context 'when the party is anonymous (no user)' do + let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } } + let(:valid_params) do + { + character: { + party_id: anon_party.id, + character_id: incoming_character.id, + position: 1, + uncap_level: 3, + transcendence_step: 0, + perpetuity: false, + rings: [{ modifier: '1', strength: '1500' }], + awakening: { id: 'character-balanced', level: 1 } + } + } + end + + it 'allows anonymous creation with correct edit_key' do + expect do + post '/api/v1/characters', params: valid_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(1) + expect(response).to have_http_status(:created) + end + + context 'when an incorrect edit_key is provided' do + let(:headers) { super().merge('X-Edit-Key' => 'wrong') } + + it 'returns an unauthorized response' do + post '/api/v1/characters', params: valid_params.to_json, headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + end + end + + describe 'POST /api/v1/characters (create action) with invalid parameters' do + context 'with missing or invalid required fields' do + let(:invalid_params) do + { + character: { + party_id: party.id, + # Missing character_id + position: 1, + uncap_level: 2, + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error messages' do + post '/api/v1/characters', params: invalid_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + # Verify that the error message on uncap_level includes a specific phrase. + expect(json_response['errors']['code'].to_s).to eq('no_character_provided') + end + end + end + + describe 'PUT /api/v1/grid_characters/:id (update action)' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid parameters' do + let(:update_params) do + { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + position: 2, + uncap_level: 4, + transcendence_step: 1, + rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }], + awakening: { id: 'character-balanced', level: 2 } + } + } + end + + it 'updates the grid character and returns the updated record' do + put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1) + end + end + + context 'with invalid parameters' do + let(:invalid_update_params) do + { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + position: 2, + uncap_level: 'invalid', + transcendence_step: 1 + } + } + end + + it 'returns unprocessable entity status with error details' do + put "/api/v1/grid_characters/#{grid_character.id}", params: invalid_update_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + expect(json_response['errors']['uncap_level'].to_s).to include('is not a number') + end + end + end + + describe 'POST /api/v1/characters/update_uncap (update uncap level action)' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid uncap level parameters' do + let(:update_uncap_params) do + { + character: { + id: grid_character.id, + party_id: party.id, + character_id: incoming_character.id, + uncap_level: 5, + transcendence_step: 1 + } + } + end + + it 'updates the uncap level and transcendence step' do + post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1) + end + end + end + + describe 'POST /api/v1/characters/resolve (conflict resolution action)' do + let!(:conflicting_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 4, + uncap_level: 3) + end + + let(:resolve_params) do + { + resolve: { + position: 4, + incoming: incoming_character.id, + conflicting: [conflicting_character.id] + } + } + end + + it 'resolves conflicts by replacing the existing grid character' do + expect(GridCharacter.exists?(conflicting_character.id)).to be true + expect do + post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers + end.to change(GridCharacter, :count).by(0) # one record deleted, one created + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response['grid_character']).to include('position' => 4) + expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'DELETE /api/v1/characters (destroy action)' do + context 'when the party is owned by a logged in user' do + let!(:grid_character) do + create(:grid_character, + party: party, + character: incoming_character, + position: 6, + uncap_level: 3) + end + + it 'destroys the grid character and returns a success response' do + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'returns not found when trying to delete a non-existent grid character' do + delete '/api/v1/characters', params: { id: '00000000-0000-0000-0000-000000000000' }.to_json, headers: headers + expect(response).to have_http_status(:not_found) + end + end + + context 'when the party is anonymous' do + let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') } + let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } } + let!(:grid_character) do + create(:grid_character, + party: anon_party, + character: incoming_character, + position: 6, + uncap_level: 3) + end + + it 'allows anonymous user to destroy the grid character' do + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers + end.to change(GridCharacter, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'prevents deletion when a logged in user attempts to delete an anonymous grid character' do + auth_headers = headers.except('X-Edit-Key') + expect do + delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: auth_headers + end.not_to change(GridCharacter, :count) + expect(response).to have_http_status(:unauthorized) + end + end + end + + # Debug hook: if any example fails and a response exists, print the error message. + after(:each) do |example| + if example.exception && defined?(response) && response.present? + error_message = begin + JSON.parse(response.body)['exception'] + rescue JSON::ParserError + response.body + end + puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}" + + # Parse once and grab the trace safely + parsed_body = JSON.parse(response.body) + trace = parsed_body.dig('traces', 'Application Trace') + ap trace if trace # Only print if trace is not nil + end + end +end diff --git a/spec/requests/grid_summons_controller_spec.rb b/spec/requests/grid_summons_controller_spec.rb new file mode 100644 index 0000000..44baf34 --- /dev/null +++ b/spec/requests/grid_summons_controller_spec.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# This request spec tests the GridSummons API endpoints using modern RSpec techniques. +RSpec.describe 'GridSummons API', type: :request do + let(:user) { create(:user) } + let(:party) { create(:party, user: user, edit_key: 'secret') } + let(:summon) { Summon.find_by(granblue_id: '2040433000') } + 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 + + RSpec::Matchers.define :have_json_error_message do |expected_message| + match do |response| + JSON.parse(response.body)['error'].to_s.include?(expected_message) + end + + failure_message do |response| + "expected error message to include '#{expected_message}', but got: #{JSON.parse(response.body)['error']}" + end + end + + describe 'POST /api/v1/summons' do + let(:valid_params) do + { + summon: { + party_id: party.id, + summon_id: summon.id, + position: 0, + main: true, + friend: false, + quick_summon: false, + uncap_level: 3, + transcendence_step: 0 + } + } + end + + context 'with valid parameters' do + it 'creates a grid summon and returns status created' do + expect do + post '/api/v1/summons', params: valid_params.to_json, headers: headers + end.to change(GridSummon, :count).by(1) + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('position' => 0) + end + end + + context 'with invalid parameters' do + # Revised: use a non-numeric uncap_level so the grid summon is built but fails validation. + let(:invalid_params) do + { + summon: { + party_id: party.id, + summon_id: summon.id, + position: 0, + main: true, + friend: false, + quick_summon: false, + uncap_level: 'invalid', + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error details' do + post '/api/v1/summons', 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') + expect(json_response['errors']).to include('uncap_level') + end + end + end + + describe 'PUT /api/v1/grid_summons/:id' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 1, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid parameters' do + let(:update_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + position: 1, + main: true, + friend: false, + quick_summon: false, + uncap_level: 4, + transcendence_step: 0 + } + } + end + + it 'updates the grid summon and returns the updated record' do + put "/api/v1/grid_summons/#{@grid_summon.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 4) + end + end + + context 'with invalid parameters' do + let(:invalid_update_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + position: 1, + main: true, + friend: false, + quick_summon: false, + uncap_level: 'invalid', + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error details' do + put "/api/v1/grid_summons/#{@grid_summon.id}", params: invalid_update_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + expect(json_response['errors']).to include('uncap_level') + end + end + end + + describe 'POST /api/v1/summons/update_uncap' do + context 'when summon has flb true, ulb false, transcendence false (max uncap 4)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 5, # attempt above allowed; should be capped at 4 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: false, transcendence: false) + end + + it 'caps the uncap level at 4 for the summon' do + post '/api/v1/summons/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).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 4) + end + end + + context 'when summon has ulb true, transcendence false (max uncap 5)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 6, # attempt above allowed; should be capped at 5 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: true, transcendence: false) + end + + it 'updates the uncap level to 5' do + post '/api/v1/summons/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).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 5) + end + end + + context 'when summon can be transcended (max uncap 6)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 7, # attempt above allowed; should be capped at 6 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: true, transcendence: true) + end + + it 'updates the uncap level to 6' do + post '/api/v1/summons/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).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 6) + end + end + end + + describe 'POST /api/v1/summons/update_quick_summon' do + context 'when grid summon position is not in [4,5,6]' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 2, + quick_summon: false) + end + + let(:update_quick_params) do + { + summon: { + id: grid_summon.id, + party_id: party.id, + summon_id: summon.id, + quick_summon: true + } + } + end + + it 'updates the quick summon flag and returns the updated summons array' do + post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('summons') + end + end + + context 'when grid summon position is in [4,5,6]' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 4, + quick_summon: false) + end + + let(:update_quick_params) do + { + summon: { + id: grid_summon.id, + party_id: party.id, + summon_id: summon.id, + quick_summon: true + } + } + end + + it 'returns no content when position is in [4,5,6]' do + post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers + expect(response).to have_http_status(:no_content) + end + end + end + + describe 'DELETE /api/v1/grid_summons/:id (destroy action)' do + context 'when the party is owned by a logged in user' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + it 'destroys the grid summon and returns a success response' do + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: headers } + .to change(GridSummon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'returns not found when trying to delete a non-existent grid summon' do + delete '/api/v1/grid_summons/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, override 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_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + it 'allows anonymous user to destroy grid summon when current_user is nil' do + # To simulate an anonymous request, we remove the Authorization header. + anonymous_headers = headers.except('Authorization') + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: anonymous_headers } + .to change(GridSummon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'prevents deletion when a logged in user attempts to delete an anonymous grid summon' do + # When a logged in user (with an access token) tries to delete a grid summon + # that belongs to an anonymous party, authorization should fail. + auth_headers = headers.except('X-Edit-Key') + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: auth_headers } + .not_to change(GridSummon, :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 diff --git a/spec/requests/grid_weapons_controller_spec.rb b/spec/requests/grid_weapons_controller_spec.rb new file mode 100644 index 0000000..bb09bf6 --- /dev/null +++ b/spec/requests/grid_weapons_controller_spec.rb @@ -0,0 +1,364 @@ +# 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 diff --git a/spec/requests/parties_controller_spec.rb b/spec/requests/parties_controller_spec.rb new file mode 100644 index 0000000..2af1713 --- /dev/null +++ b/spec/requests/parties_controller_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Parties API', type: :request do + let(:user) { create(:user) } + 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 + + describe 'POST /api/v1/parties' do + context 'with valid attributes' do + let(:valid_attributes) do + { + party: { + name: 'Test Party', + description: 'A party for testing', + raid_id: nil, + visibility: 1, + full_auto: false, + auto_guard: false, + charge_attack: true, + clear_time: 500, + button_count: 3, + turn_count: 4, + chain_count: 2 + } + } + end + + it 'creates a new party and returns status created' do + expect do + post '/api/v1/parties', params: valid_attributes.to_json, headers: headers + end.to change(Party, :count).by(1) + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['party']['name']).to eq('Test Party') + end + end + end + + describe 'GET /api/v1/parties/:id' do + let!(:party) { create(:party, user: user, name: 'Visible Party', visibility: 1) } + + context 'when the party is public or owned' do + it 'returns the party details' do + get "/api/v1/parties/#{party.shortcode}", headers: headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['party']['name']).to eq('Visible Party') + end + end + + context 'when the party is private and not owned' do + let!(:private_party) { create(:party, user: create(:user), visibility: 3, name: 'Private Party') } + it 'returns unauthorized' do + get "/api/v1/parties/#{private_party.shortcode}", headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/parties/:id' do + let!(:party) { create(:party, user: user, name: 'Old Name') } + let(:update_attributes) do + { party: { name: 'New Name', description: 'Updated description' } } + end + + it 'updates the party and returns the updated party' do + put "/api/v1/parties/#{party.id}", params: update_attributes.to_json, headers: headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['party']['name']).to eq('New Name') + expect(json['party']['description']).to eq('Updated description') + end + end + + describe 'DELETE /api/v1/parties/:id' do + let!(:party) { create(:party, user: user) } + it 'destroys the party and returns the destroyed party view' do + delete "/api/v1/parties/#{party.id}", headers: headers + expect(response).to have_http_status(:ok) + expect { party.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'POST /api/v1/parties/:id/remix' do + let!(:party) { create(:party, user: user, name: 'Original Party') } + let(:remix_params) { { party: { local_id: party.local_id } } } + it 'creates a remixed copy of the party' do + post "/api/v1/parties/#{party.shortcode}/remix", params: remix_params.to_json, headers: headers + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['party']['source_party']['id']).to eq(party.id) + end + end + + describe 'GET /api/v1/parties' do + before { create_list(:party, 3, user: user, visibility: 1) } + it 'lists parties with pagination' do + get '/api/v1/parties', headers: headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['results']).to be_an(Array) + expect(json['meta']).to have_key('count') + end + end + + describe 'GET /api/v1/parties/favorites' do + let(:other_user) { create(:user) } + let!(:party) { create(:party, user: other_user, visibility: 1) } + + before do + # Create associated records so that the party meets the default filtering minimums: + # - At least 3 characters, + # - At least 5 weapons, + # - At least 2 summons. + create_list(:grid_character, 3, party: party) + create_list(:grid_weapon, 5, party: party) + create_list(:grid_summon, 2, party: party) + party.reload # Reload to update counter caches. + + ap "DEBUG: Party counts - characters: #{party.characters_count}, weapons: #{party.weapons_count}, summons: #{party.summons_count}" + + create(:favorite, user: user, party: party) + end + + before { create(:favorite, user: user, party: party) } + + it 'lists parties favorited by the current user' do + # Debug: print IDs returned by the join query (this code can be removed later) + favorite_ids = Party.joins(:favorites).where(favorites: { user_id: user.id }).distinct.pluck(:id) + ap "DEBUG: Created party id: #{party.id}" + ap "DEBUG: Favorite party ids: #{favorite_ids.inspect}" + + get '/api/v1/parties/favorites', headers: headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['results']).not_to be_empty + expect(json['results'].first).to include('favorited' => true) + end + end + + describe 'Preview Management Endpoints' do + let(:user) { create(:user) } + let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 'default') } + let(:headers) do + { 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}", + 'Content-Type' => 'application/json' } + end + + describe 'GET /api/v1/parties/:id/preview' do + before do + # Stub send_file on the correctly namespaced controller. + allow_any_instance_of(Api::V1::PartiesController).to receive(:send_file) do |instance, *args| + instance.render plain: 'dummy image content', content_type: 'image/png', status: 200 + end + end + + it 'serves the preview image (returns 200)' do + get "/api/v1/parties/#{party.shortcode}/preview", headers: headers + expect(response).to have_http_status(200) + expect(response.content_type).to eq('image/png; charset=utf-8') + expect(response.body).to eq('dummy image content') + end + end + + describe 'GET /api/v1/parties/:id/preview_status' do + it 'returns the preview status of the party' do + get "/api/v1/parties/#{party.shortcode}/preview_status", headers: headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to have_key('state') + end + end + + describe 'POST /api/v1/parties/:id/regenerate_preview' do + it 'forces preview regeneration when requested by the owner' do + post "/api/v1/parties/#{party.shortcode}/regenerate_preview", headers: headers + expect(response.status).to(satisfy { |s| [200, 422].include?(s) }) + end + end + end + + # Debug block: prints debug info if an example fails. + 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f0bf096 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'simplecov' + +SimpleCov.start + +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/support/matchers/have_error_on.rb b/spec/support/matchers/have_error_on.rb new file mode 100644 index 0000000..0919cd1 --- /dev/null +++ b/spec/support/matchers/have_error_on.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This custom matcher checks that a model's errors on a given attribute include a specific phrase. +RSpec::Matchers.define :have_error_on do |attribute, expected_phrase| + match do |model| + model.valid? && model.errors[attribute].any? { |msg| msg.include?(expected_phrase) } + end + + failure_message do |model| + "expected errors on #{attribute} to include '#{expected_phrase}', but got: #{model.errors[attribute].join(', ')}" + end +end