diff --git a/Gemfile b/Gemfile index ac583dd..0497515 100644 --- a/Gemfile +++ b/Gemfile @@ -76,6 +76,11 @@ gem 'strscan' # New Relic Ruby Agent gem 'newrelic_rpm' +# The Sentry SDK for Rails +gem "stackprof" +gem "sentry-ruby" +gem "sentry-rails" + group :doc do gem 'apipie-rails' gem 'sdoc' diff --git a/Gemfile.lock b/Gemfile.lock index 5c8975a..5b98794 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -377,6 +377,12 @@ GEM sdoc (2.6.1) rdoc (>= 5.0) securerandom (0.4.1) + sentry-rails (5.22.4) + railties (>= 5.0) + sentry-ruby (~> 5.22.4) + sentry-ruby (5.22.4) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) shoulda-matchers (6.4.0) activesupport (>= 5.2.0) sidekiq (7.3.8) @@ -420,6 +426,7 @@ GEM activesupport (>= 6.1) sprockets (>= 3.0.0) squasher (0.8.0) + stackprof (0.2.27) stringio (3.1.2) strscan (3.1.2) thor (1.3.2) @@ -491,6 +498,8 @@ DEPENDENCIES rubocop rufus-scheduler sdoc + sentry-rails + sentry-ruby shoulda-matchers sidekiq simplecov @@ -499,6 +508,7 @@ DEPENDENCIES spring-commands-rspec sprockets-rails squasher (>= 0.6.0) + stackprof strscan will_paginate (~> 3.3) diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index b55f096..1fce768 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -105,16 +105,20 @@ module Api # Lists parties based on query parameters. def index - query = build_parties_query + query = build_filtered_query(build_common_base_query) @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) render_paginated_parties(@parties) end - # Lists parties favorited by the current user. + # GET /api/v1/parties/favorites def favorites raise Api::V1::UnauthorizedError unless current_user - ap "Total favorites in DB: #{Favorite.count}" - query = build_parties_query(favorites: true) + + base_query = build_common_base_query + .joins(:favorites) + .where(favorites: { user_id: current_user.id }) + .distinct + query = build_filtered_query(base_query) @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) render_paginated_parties(@parties) end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 6e6cec2..bec71eb 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -55,34 +55,39 @@ module Api if @user.nil? render_not_found_response('user') else - conditions = build_conditions - conditions[:user_id] = @user.id - - favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited" - parties = Party.where(conditions) - .where(name_quality) - .where(user_quality) - .where(original) - .where(privacy) - .includes(:favorites) - .select(Party.arel_table[Arel.star]) - .select( - Arel.sql(favorites_query) - ) - .order(created_at: :desc) - .paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE) - - count = Party.where(conditions).count - + base_query = Party.includes( + { raid: :group }, + :job, + :user, + :skill0, + :skill1, + :skill2, + :skill3, + :guidebook1, + :guidebook2, + :guidebook3, + { characters: :character }, + { weapons: :weapon }, + { summons: :summon } + ) + # Restrict to parties belonging to the profile owner + base_query = base_query.where(user_id: @user.id) + skip_privacy = (current_user&.id == @user.id) + query = PartyQueryBuilder.new( + base_query, + params: params, + current_user: current_user, + options: { skip_privacy: skip_privacy } + ).build + parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE) + count = query.count render json: UserBlueprint.render(@user, view: :profile, root: 'profile', parties: parties, - meta: { - count: count, - total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1, - per_page: COLLECTION_PER_PAGE - }) + meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE }, + current_user: current_user + ) end end @@ -98,6 +103,36 @@ module Api private + def build_profile_query(profile_user) + query = Party.includes( + { raid: :group }, + :job, + :user, + :skill0, + :skill1, + :skill2, + :skill3, + :guidebook1, + :guidebook2, + :guidebook3, + { characters: :character }, + { weapons: :weapon }, + { summons: :summon } + ) + # Restrict to parties belonging to the profile’s owner. + query = query.where(user_id: profile_user.id) + # Then apply the additional filters that we normally use: + query = query.where(name_quality) + .where(user_quality) + .where(original) + .where(privacy) + # And if there are any request-supplied filters, includes, or excludes: + query = apply_filters(query) if params[:filters].present? + query = apply_includes(query, params[:includes]) if params[:includes].present? + query = apply_excludes(query, params[:excludes]) if params[:excludes].present? + query.order(created_at: :desc) + end + def build_conditions params = request.params diff --git a/app/controllers/concerns/party_querying_concern.rb b/app/controllers/concerns/party_querying_concern.rb index c8eb946..096cadc 100644 --- a/app/controllers/concerns/party_querying_concern.rb +++ b/app/controllers/concerns/party_querying_concern.rb @@ -4,30 +4,28 @@ 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( + # Returns the common base query for Parties including all necessary associations. + def build_common_base_query + Party.includes( { raid: :group }, :job, :user, - :skill0, :skill1, :skill2, :skill3, - :guidebook1, :guidebook2, :guidebook3, + :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 + + # Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.) + def build_filtered_query(base_query) + PartyQueryBuilder.new(base_query, params: params, current_user: current_user).build end # Renders paginated parties using PartyBlueprint. @@ -39,115 +37,12 @@ module PartyQueryingConcern meta: { count: parties.total_entries, total_pages: parties.total_pages, - per_page: PartyConstants::COLLECTION_PER_PAGE + per_page: 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 } diff --git a/app/services/party_query_builder.rb b/app/services/party_query_builder.rb new file mode 100644 index 0000000..f151ab8 --- /dev/null +++ b/app/services/party_query_builder.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +# PartyQueryBuilder is responsible for building an ActiveRecord query for parties +# by applying a series of filters, includes, and excludes based on request parameters. +# It is used to build both the general parties query and specialized queries (like +# for a user’s profile) while keeping the filtering logic DRY. +# +# Usage: +# base_query = Party.includes(:user, :job, ... ) # a starting query +# query_builder = PartyQueryBuilder.new(base_query, params: params, current_user: current_user, options: { default_status: 'active' }) +# final_query = query_builder.build +# +class PartyQueryBuilder + # Initialize with a base query, a params hash, and the current user. + # Options may include default filters like :default_status, default counts, and max values. + def initialize(base_query, params:, current_user:, options: {}) + @base_query = base_query + @params = params + @current_user = current_user + @options = options + end + + # Builds the final ActiveRecord query by applying filters, includes, and excludes. + # + # Edge cases handled: + # - If a parameter is missing or blank, default values are used. + # - If no recency is provided, no date range is applied. + # - If includes/excludes parameters are missing, those methods are skipped. + # + # Also applies a default status filter (if provided via options) using a dedicated callback. + def build + query = @base_query + query = apply_filters(query) + query = apply_default_status(query) if @options[:default_status] + 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.order(created_at: :desc) + end + + private + + # Applies filtering conditions to the given query. + # Combines generic filters (like element, raid_id, created_at) with object count ranges. + # + # Example edge case: If the request does not specify 'characters_count', + # then the default (e.g. 3) will be used, with the upper bound coming from a constant. + def apply_filters(query) + conditions = build_filters + query = query.where(conditions) + # If name_quality filtering is enabled via params, apply it. + query = query.where(name_quality) if @params[:name_quality].present? + query.where( + weapons_count: build_count(@params[:weapons_count], default_weapons_count)..max_weapons, + characters_count: build_count(@params[:characters_count], default_characters_count)..max_characters, + summons_count: build_count(@params[:summons_count], default_summons_count)..max_summons + ) + end + + # Example callback method: if no explicit status filter is provided, we may want + # to force the query to include only records with a given default status. + # This method encapsulates that behavior. + def apply_default_status(query) + query.where(status: @options[:default_status]) + end + + # Applies privacy settings based on whether the current user is an admin. + def apply_privacy_settings(query) + # If the options say to skip privacy filtering (e.g. when viewing your own profile), + # then return the query unchanged. + return query if @options[:skip_privacy] + + # Otherwise, if not admin, only show public parties. + return query if @current_user&.admin? + + query.where('visibility = ?', 1) + end + + # Builds a hash of filtering conditions from the params. + # + # Uses guard clauses to ignore keys when a parameter is missing. + 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]) + }.compact + end + + # Returns a date range based on the 'recency' parameter. + # If recency is not provided, returns nil so no date filter is applied. + 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 from the parameter or a default value if the parameter is blank. + def build_count(value, default_value) + value.blank? ? default_value : value.to_i + end + + # Processes an option parameter. + # Returns the integer value unless the value is blank or equal to -1. + def build_option(value) + value.to_i unless value.blank? || value.to_i == -1 + end + + # Applies "includes" filtering to the query based on a comma-separated string. + # For each provided ID, it adds a condition using an EXISTS subquery. + # + # Edge case example: If an ID does not start with a known prefix, + # grid_table_and_object_table returns [nil, nil] and the condition is skipped. + 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 to the query based on a comma-separated string. + # Works similarly to apply_includes, but with a NOT EXISTS clause. + 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 first character to the corresponding grid table and object table names. + # + # For example: + # '3...' => %w[grid_characters characters] + # '2...' => %w[grid_summons summons] + # '1...' => %w[grid_weapons weapons] + # Returns [nil, nil] for unknown prefixes. + 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 + + # Default values and maximum limits for counts. + def default_weapons_count + @options[:default_weapons_count] || 5; + end + + def default_characters_count + @options[:default_characters_count] || 3; + end + + def default_summons_count + @options[:default_summons_count] || 2; + end + + def max_weapons + @options[:max_weapons] || 13; + end + + def max_characters + @options[:max_characters] || 5; + end + + def max_summons + @options[:max_summons] || 8; + end + + # Stub method for name quality filtering. + # In your application, this might be defined in a helper or concern. + def name_quality + # Example: exclude parties with names like 'Untitled' (edge case) + "name NOT LIKE 'Untitled%'" + end + + # Stub method for user quality filtering. + # Adjust as needed for your actual implementation. + def user_quality + 'user_id IS NOT NULL' + end + + # Stub method for original filtering. + def original + 'source_party_id IS NULL' + end + + # Stub method for privacy filtering. + # Here we assume that if the current user is not an admin, only public parties (visibility = 1) are returned. + def privacy + return nil if @current_user && @current_user.admin? + + 'visibility = 1' + end +end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..bfa3461 --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Sentry.init do |config| + config.breadcrumbs_logger = [:active_support_logger] + config.dsn = ENV['SENTRY_DSN'] + config.enable_tracing = true +end diff --git a/config/newrelic.yml b/config/newrelic.yml new file mode 100644 index 0000000..9f25061 --- /dev/null +++ b/config/newrelic.yml @@ -0,0 +1,66 @@ +# +# This file configures the New Relic Agent. New Relic monitors Ruby, Java, +# .NET, PHP, Python, Node, and Go applications with deep visibility and low +# overhead. For more information, visit www.newrelic.com. +# +# Generated October 28, 2022 +# +# This configuration file is custom generated for NewRelic Administration +# +# For full documentation of agent configuration options, please refer to +# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration + +common: &default_settings + # Required license key associated with your New Relic account. + license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %> + + # Your application name. Renaming here affects where data displays in New + # Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications + app_name: 'hensei-api' + + distributed_tracing: + enabled: true + + # To disable the agent regardless of other settings, uncomment the following: + + # agent_enabled: false + + # Logging level for log/newrelic_agent.log + log_level: info + + application_logging: + # If `true`, all logging-related features for the agent can be enabled or disabled + # independently. If `false`, all logging-related features are disabled. + enabled: true + forwarding: + # If `true`, the agent captures log records emitted by this application. + enabled: true + # Defines the maximum number of log records to buffer in memory at a time. + max_samples_stored: 10000 + metrics: + # If `true`, the agent captures metrics related to logging for this application. + enabled: true + local_decorating: + # If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans. + # This requires a log forwarder to send your log files to New Relic. + # This should not be used when forwarding is enabled. + enabled: false + +# Environment-specific settings are in this section. +# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. +# If your application has other named environments, configure them here. +development: + <<: *default_settings + app_name: 'hensei-api (Development)' + +test: + <<: *default_settings + # It doesn't make sense to report to New Relic from automated test runs. + monitor_mode: false + +staging: + <<: *default_settings + app_name: 'hensei-api (Staging)' + +production: + <<: *default_settings diff --git a/lib/granblue/transformers/summon_transformer.rb b/lib/granblue/transformers/summon_transformer.rb index eefb380..bbfb5fb 100644 --- a/lib/granblue/transformers/summon_transformer.rb +++ b/lib/granblue/transformers/summon_transformer.rb @@ -40,54 +40,56 @@ module Granblue def transform Rails.logger.info "[TRANSFORM] Starting SummonTransformer#transform" - # Validate that input data is a Hash unless data.is_a?(Hash) Rails.logger.error "[TRANSFORM] Invalid summon data structure" Rails.logger.error "[TRANSFORM] Data class: #{data.class}" return [] end - summons = [] - # Process each summon in the data - data.each_value do |summon_data| + # Determine the maximum index from the keys (assumed to be numeric strings). + max_index = data.keys.map(&:to_i).max || 0 + # Pre-allocate an array so that key "1" ends up at index 0, etc. + summons = Array.new(max_index) + + # Process keys sorted numerically. + data.keys.sort_by(&:to_i).each do |key| + summon_data = data[key] Rails.logger.debug "[TRANSFORM] Processing summon: #{summon_data['master']['name'] if summon_data['master']}" - # Extract master and parameter data master, param = get_master_param(summon_data) unless master && param Rails.logger.debug "[TRANSFORM] Skipping summon - missing master or param data" next end - # Build base summon hash with required attributes + # Build the base summon hash. summon = { - name: master['name'], # Summon's display name - id: master['id'], # Unique identifier - uncap: param['evolution'].to_i # Current uncap level + name: master['name'], + id: master['id'], + uncap: param['evolution'].to_i } - Rails.logger.debug "[TRANSFORM] Base summon data: #{summon}" + # Calculate and add transcendence level. + level = param['level'].to_i + summon[:transcend] = calculate_transcendence_level(level) - # Add transcendence level for highly uncapped summons - if summon[:uncap] > 5 - level = param['level'].to_i - trans = calculate_transcendence_level(level) - summon[:transcend] = trans - Rails.logger.debug "[TRANSFORM] Added transcendence level: #{trans}" - end - - # Mark quick summon status if this summon matches quick_summon_id + # Mark quick summon status if this summon matches quick_summon_id. if @quick_summon_id && param['id'].to_s == @quick_summon_id.to_s summon[:qs] = true - Rails.logger.debug "[TRANSFORM] Marked as quick summon" end - summons << summon + # Include subaura (sub_skill) information if present. + if summon_data['sub_skill'].is_a?(Hash) && summon_data['sub_skill']['name'] + summon[:sub_aura] = summon_data['sub_skill']['name'] + end + + # Place the summon in the proper 0-indexed slot. + summons[key.to_i - 1] = summon Rails.logger.info "[TRANSFORM] Successfully processed summon #{summon[:name]}" end - Rails.logger.info "[TRANSFORM] Completed processing #{summons.length} summons" - summons + Rails.logger.info "[TRANSFORM] Completed processing #{summons.compact.length} summons" + summons.compact end private diff --git a/spec/controllers/concerns/party_querying_concern_spec.rb b/spec/controllers/concerns/party_querying_concern_spec.rb index 40329ee..25aeeae 100644 --- a/spec/controllers/concerns/party_querying_concern_spec.rb +++ b/spec/controllers/concerns/party_querying_concern_spec.rb @@ -20,112 +20,6 @@ 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') } diff --git a/spec/requests/parties_controller_spec.rb b/spec/requests/parties_controller_spec.rb index 2af1713..9df3b31 100644 --- a/spec/requests/parties_controller_spec.rb +++ b/spec/requests/parties_controller_spec.rb @@ -146,7 +146,7 @@ RSpec.describe 'Parties API', type: :request do describe 'Preview Management Endpoints' do let(:user) { create(:user) } - let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 'default') } + let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 0) } let(:headers) do { 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}", 'Content-Type' => 'application/json' } diff --git a/spec/services/party_query_builder_spec.rb b/spec/services/party_query_builder_spec.rb new file mode 100644 index 0000000..983387f --- /dev/null +++ b/spec/services/party_query_builder_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +# This spec verifies that PartyQueryBuilder correctly builds an ActiveRecord +# query based on provided parameters. It tests both the overall build process and +# individual helper methods. +# +require 'rails_helper' + +RSpec.describe PartyQueryBuilder, type: :model do + let(:base_query) { Party.all } # Use Party.all as our starting query. + let(:params) do + { + element: '3', + raid: '123e4567-e89b-12d3-a456-426614174000', + recency: '3600', + full_auto: '1', + auto_guard: '0', + charge_attack: '1', + weapons_count: '', # blank => should use default + characters_count: '4', + summons_count: '2', + includes: '300001,200002', + excludes: '100003', + name_quality: '1' # dummy flag for testing name_quality clause + } + end + let(:current_user) { create(:user) } + let(:options) { {} } + + subject { described_class.new(base_query, params: params, current_user: current_user, options: options) } + + describe '#build' do + context 'with all filters provided' do + it 'returns an ActiveRecord::Relation with filters applied' do + query = subject.build + sql = query.to_sql + + # Expect the element filter to be applied (converted to integer) + expect(sql).to include('"parties"."element" = 3') + # Expect the raid filter to be applied + expect(sql).to match(/"parties"."raid_id"\s*=\s*'123e4567-e89b-12d3-a456-426614174000'/) + # Expect a created_at range condition from the recency param + expect(sql).to include('"parties"."created_at" BETWEEN') + # Expect object count filtering for characters_count (range clause) + expect(sql).to include('characters_count') + # Expect the name quality stub condition + expect(sql).to include("name NOT LIKE 'Untitled%'") + # Expect that includes and excludes clauses (EXISTS and NOT EXISTS) are added + expect(sql).to include('EXISTS (') + expect(sql).to include('NOT EXISTS (') + end + end + + context 'when default_status option is provided' do + let(:options) { { default_status: 'active' } } + it 'applies the default status filter' do + query = subject.build + sql = query.to_sql + expect(sql).to include('"parties"."status" = \'active\'') + end + end + + context 'when current_user is not admin and skip_privacy is not set' do + it 'applies the privacy filter (visibility = 1)' do + query = subject.build + sql = query.to_sql + expect(sql).to include('visibility = 1') + end + end + + context 'when current_user is admin' do + let(:current_user) { create(:user, role: 9) } + it 'does not apply the privacy filter' do + query = subject.build + sql = query.to_sql + expect(sql).not_to include('visibility = 1') + end + end + + context 'when skip_privacy option is set' do + let(:options) { { skip_privacy: true } } + it 'does not apply the privacy filter even for non-admins' do + query = subject.build + sql = query.to_sql + expect(sql).not_to include('visibility = 1') + end + end + + it 'returns an ActiveRecord::Relation object' do + expect(subject.build).to be_a(ActiveRecord::Relation) + end + end + + describe 'private helper methods' do + describe '#grid_table_and_object_table' do + it 'returns grid_characters and characters for an id starting with "3"' do + result = subject.send(:grid_table_and_object_table, '300001') + expect(result).to eq(%w[grid_characters characters]) + end + + it 'returns grid_summons and summons for an id starting with "2"' do + result = subject.send(:grid_table_and_object_table, '200001') + expect(result).to eq(%w[grid_summons summons]) + end + + it 'returns grid_weapons and weapons for an id starting with "1"' do + result = subject.send(:grid_table_and_object_table, '100001') + expect(result).to eq(%w[grid_weapons weapons]) + end + + it 'returns [nil, nil] for an unknown prefix' do + result = subject.send(:grid_table_and_object_table, '900001') + expect(result).to eq([nil, nil]) + end + end + + describe '#build_date_range' do + it 'returns a range when recency parameter is provided' do + range = subject.send(:build_date_range) + expect(range).to be_a(Range) + expect(range.begin).to be <= DateTime.current + # The range should span from beginning of the day to now + expect(range.end).to be >= DateTime.current - 3600.seconds + end + + it 'returns nil when recency parameter is missing' do + new_params = params.dup + new_params.delete(:recency) + builder = described_class.new(base_query, params: new_params, current_user: current_user, options: options) + expect(builder.send(:build_date_range)).to be_nil + end + end + + describe '#build_count' do + it 'returns the default value when given a blank parameter' do + expect(subject.send(:build_count, '', 3)).to eq(3) + end + + it 'converts string numbers to integer' do + expect(subject.send(:build_count, '5', 3)).to eq(5) + end + end + + describe '#build_option' do + it 'returns nil if the value is blank' do + expect(subject.send(:build_option, '')).to be_nil + end + + it 'returns nil if the value is -1' do + expect(subject.send(:build_option, '-1')).to be_nil + end + + it 'returns the integer value for valid input' do + expect(subject.send(:build_option, '2')).to eq(2) + end + end + + describe '#apply_includes and #apply_excludes' do + context 'with a valid includes parameter' do + let(:includes_param) { '300001' } # should map to grid_characters/characters + it 'adds an EXISTS clause to the query' do + query = subject.send(:apply_includes, base_query, includes_param) + sql = query.to_sql + expect(sql).to include('EXISTS (') + expect(sql).to include('grid_characters') + expect(sql).to include('characters') + end + end + + context 'with a valid excludes parameter' do + let(:excludes_param) { '100001' } # should map to grid_weapons/weapons + it 'adds a NOT EXISTS clause to the query' do + query = subject.send(:apply_excludes, base_query, excludes_param) + sql = query.to_sql + expect(sql).to include('NOT EXISTS (') + expect(sql).to include('grid_weapons') + expect(sql).to include('weapons') + end + end + + context 'with an unknown prefix in includes/excludes' do + let(:bad_param) { '900001' } + it 'skips the condition for includes' do + query = subject.send(:apply_includes, base_query, bad_param) + sql = query.to_sql + expect(sql).not_to include('EXISTS (') + end + + it 'skips the condition for excludes' do + query = subject.send(:apply_excludes, base_query, bad_param) + sql = query.to_sql + expect(sql).not_to include('NOT EXISTS (') + end + end + end + end +end