diff --git a/app/blueprints/api/v1/party_blueprint.rb b/app/blueprints/api/v1/party_blueprint.rb index 845c231..3e81bee 100644 --- a/app/blueprints/api/v1/party_blueprint.rb +++ b/app/blueprints/api/v1/party_blueprint.rb @@ -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/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/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/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/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/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/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