Migrate to Query Builder (#179)

* Moved queries into PartyQueryBuilder service

PartyQueryBuilder supersedes PartyQueryingConcern as it is also used for UsersController (and is our fix for profiles being broken)

* Implement PartyQueryBuilder in controllers

* Update summon_transformer.rb

This should fix the transformer so that we properly capture summons and subaura summons

* Update parties_controller_spec.rb

* Add NewRelic license key

* Add Sentry

Why not?
This commit is contained in:
Justin Edmund 2025-02-12 23:43:02 -08:00 committed by GitHub
parent d6300f7aeb
commit a042847aab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 615 additions and 279 deletions

View file

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

View file

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

View file

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

View file

@ -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)
base_query = Party.includes(
{ raid: :group },
:job,
:user,
:skill0,
:skill1,
:skill2,
:skill3,
:guidebook1,
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: :weapon },
{ summons: :summon }
)
.order(created_at: :desc)
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
count = Party.where(conditions).count
# 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 profiles 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

View file

@ -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
# 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 ids 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 }

View file

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

View file

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

66
config/newrelic.yml Normal file
View file

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

View file

@ -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}"
# Add transcendence level for highly uncapped summons
if summon[:uncap] > 5
# Calculate and add transcendence level.
level = param['level'].to_i
trans = calculate_transcendence_level(level)
summon[:transcend] = trans
Rails.logger.debug "[TRANSFORM] Added transcendence level: #{trans}"
end
summon[:transcend] = calculate_transcendence_level(level)
# 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

View file

@ -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') }

View file

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

View file

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