Refactored PartiesController
- Split PartiesController into three concerns - Implemented testing for PartiesController and two concerns - Implemented fixes across other files to ensure PartiesController tests pass - Added Favorites factory
This commit is contained in:
parent
ad9a6d7b5f
commit
a1818ec4c6
12 changed files with 847 additions and 638 deletions
|
|
@ -41,7 +41,7 @@ module Api
|
||||||
view :full do
|
view :full do
|
||||||
# Primary object associations
|
# Primary object associations
|
||||||
include_view :nested_objects # Characters, Weapons, Summons
|
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
|
include_view :job_metadata # Accessory, Skills, Guidebooks
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -85,11 +85,15 @@ module Api
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested_metadata do
|
view :source_party do
|
||||||
association :source_party,
|
association :source_party,
|
||||||
blueprint: PartyBlueprint,
|
blueprint: PartyBlueprint,
|
||||||
view: :minimal,
|
view: :preview,
|
||||||
if: ->(_field_name, party, _options) { party.source_party_id.present? }
|
if: ->(_field_name, party, _options) { party.source_party_id.present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
view :remix_metadata do
|
||||||
|
include_view :source_party
|
||||||
|
|
||||||
# Re-added remixes association
|
# Re-added remixes association
|
||||||
association :remixes,
|
association :remixes,
|
||||||
|
|
@ -127,6 +131,11 @@ module Api
|
||||||
fields :edit_key
|
fields :edit_key
|
||||||
end
|
end
|
||||||
|
|
||||||
|
view :remixed do
|
||||||
|
include_view :created
|
||||||
|
include_view :source_party
|
||||||
|
end
|
||||||
|
|
||||||
# Destroyed view
|
# Destroyed view
|
||||||
view :destroyed do
|
view :destroyed do
|
||||||
fields :name, :description, :created_at, :updated_at
|
fields :name, :description, :created_at, :updated_at
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ module Api
|
||||||
# Controller for managing party-related operations in the API
|
# Controller for managing party-related operations in the API
|
||||||
# @api public
|
# @api public
|
||||||
class PartiesController < Api::V1::ApiController
|
class PartiesController < Api::V1::ApiController
|
||||||
before_action :set_from_slug,
|
include PartyAuthorizationConcern
|
||||||
except: %w[create destroy update index favorites]
|
include PartyQueryingConcern
|
||||||
before_action :set, only: %w[update destroy]
|
include PartyPreviewConcern
|
||||||
before_action :authorize, only: %w[update destroy]
|
|
||||||
|
|
||||||
# == Constants
|
# Constants used for filtering validations.
|
||||||
|
|
||||||
# Maximum number of characters allowed in a party
|
# Maximum number of characters allowed in a party
|
||||||
MAX_CHARACTERS = 5
|
MAX_CHARACTERS = 5
|
||||||
|
|
@ -33,728 +32,163 @@ module Api
|
||||||
# Default maximum clear time in seconds
|
# Default maximum clear time in seconds
|
||||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
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
|
# Creates a new party with optional user association
|
||||||
# @return [void]
|
# @return [void]
|
||||||
|
# Creates a new party.
|
||||||
def create
|
def create
|
||||||
# Build the party with the provided parameters and assign the user
|
|
||||||
party = Party.new(party_params)
|
party = Party.new(party_params)
|
||||||
party.user = current_user if current_user
|
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 party_params && party_params[:raid_id].present?
|
||||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||||
party.extra = raid.group.extra
|
party.extra = raid.group.extra
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Save and render the party, triggering preview generation if the party is ready
|
|
||||||
if party.save
|
if party.save
|
||||||
party.schedule_preview_generation if party.ready_for_preview?
|
party.schedule_preview_generation if party.ready_for_preview?
|
||||||
render json: PartyBlueprint.render(party, view: :created, root: :party),
|
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
||||||
status: :created
|
|
||||||
else
|
else
|
||||||
render_validation_error_response(party)
|
render_validation_error_response(party)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Shows a specific party if the user has permission to view it
|
# Shows a specific party.
|
||||||
# @return [void]
|
|
||||||
def show
|
def show
|
||||||
# If a party is private, check that the user is the owner or an admin
|
return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
|
||||||
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
|
|
||||||
return render_unauthorized_response
|
if @party
|
||||||
|
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||||
|
else
|
||||||
|
render_not_found_response('project')
|
||||||
end
|
end
|
||||||
|
|
||||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
|
|
||||||
|
|
||||||
render_not_found_response('project')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Updates an existing party's attributes
|
# Updates an existing party.
|
||||||
# @return [void]
|
|
||||||
def update
|
def update
|
||||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||||
|
|
||||||
if party_params && party_params[:raid_id]
|
if party_params && party_params[:raid_id]
|
||||||
raid = Raid.find_by(id: party_params[:raid_id])
|
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||||
@party.extra = raid.group.extra
|
@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
|
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
|
end
|
||||||
|
|
||||||
# Deletes a party if the user has permission
|
# Deletes a party.
|
||||||
# @return [void]
|
|
||||||
def destroy
|
def destroy
|
||||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Extended Party Actions
|
# Extended Party Actions
|
||||||
|
|
||||||
# Creates a copy of an existing party with attribution
|
# Creates a remixed copy of an existing party.
|
||||||
# @return [void]
|
|
||||||
def remix
|
def remix
|
||||||
new_party = @party.amoeba_dup
|
new_party = @party.amoeba_dup
|
||||||
new_party.attributes = {
|
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
|
||||||
user: current_user,
|
new_party.local_id = party_params[:local_id] if party_params
|
||||||
name: remixed_name(@party.name),
|
|
||||||
source_party: @party,
|
|
||||||
remix: true
|
|
||||||
}
|
|
||||||
|
|
||||||
new_party.local_id = party_params[:local_id] unless party_params.nil?
|
|
||||||
|
|
||||||
if new_party.save
|
if new_party.save
|
||||||
# Remixed parties should have content, so generate preview
|
|
||||||
new_party.schedule_preview_generation
|
new_party.schedule_preview_generation
|
||||||
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
|
render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
|
||||||
status: :created
|
|
||||||
else
|
else
|
||||||
render_validation_error_response(new_party)
|
render_validation_error_response(new_party)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Lists parties based on various filter criteria
|
# Lists parties based on query parameters.
|
||||||
# @return [void]
|
|
||||||
def index
|
def index
|
||||||
query = build_parties_query
|
query = build_parties_query
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||||
render_paginated_parties(@parties)
|
render_paginated_parties(@parties)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Lists parties favorited by the current user
|
# Lists parties favorited by the current user.
|
||||||
# @return [void]
|
|
||||||
def favorites
|
def favorites
|
||||||
raise Api::V1::UnauthorizedError unless current_user
|
raise Api::V1::UnauthorizedError unless current_user
|
||||||
|
ap "Total favorites in DB: #{Favorite.count}"
|
||||||
query = build_parties_query(favorites: true)
|
query = build_parties_query(favorites: true)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@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)
|
render_paginated_parties(@parties)
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Preview Management
|
# Preview Management
|
||||||
|
|
||||||
# Serves the party's preview image
|
# Serves the party's preview image
|
||||||
# @return [void]
|
# @return [void]
|
||||||
|
# Serves the party's preview image.
|
||||||
def preview
|
def preview
|
||||||
coordinator = PreviewService::Coordinator.new(@party)
|
party_preview(@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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns the current status of a party's preview
|
# Returns the current preview status of a party.
|
||||||
# @return [void]
|
|
||||||
def preview_status
|
def preview_status
|
||||||
party = Party.find_by!(shortcode: params[:id])
|
party = Party.find_by!(shortcode: params[:id])
|
||||||
render json: {
|
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? }
|
||||||
state: party.preview_state,
|
|
||||||
generated_at: party.preview_generated_at,
|
|
||||||
ready_for_preview: party.ready_for_preview?
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Forces regeneration of a party's preview image
|
# Forces regeneration of the party preview.
|
||||||
# @return [void]
|
|
||||||
def regenerate_preview
|
def regenerate_preview
|
||||||
party = Party.find_by!(shortcode: params[:id])
|
party = Party.find_by!(shortcode: params[:id])
|
||||||
|
return render_unauthorized_response unless current_user && party.user_id == current_user.id
|
||||||
# Ensure only party owner can force regeneration
|
|
||||||
unless current_user && party.user_id == current_user.id
|
|
||||||
return render_unauthorized_response
|
|
||||||
end
|
|
||||||
|
|
||||||
preview_service = PreviewService::Coordinator.new(party)
|
preview_service = PreviewService::Coordinator.new(party)
|
||||||
if preview_service.force_regenerate
|
if preview_service.force_regenerate
|
||||||
render json: { status: 'Preview regeneration started' }
|
render json: { status: 'Preview regeneration started' }
|
||||||
else
|
else
|
||||||
render json: { error: 'Preview regeneration failed' },
|
render json: { error: 'Preview regeneration failed' }, status: :unprocessable_entity
|
||||||
status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Builds the base query for parties, optionally including favorites-specific conditions.
|
# Loads the party by its shortcode.
|
||||||
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]
|
|
||||||
def set_from_slug
|
def set_from_slug
|
||||||
@party = Party.includes(
|
@party = Party.includes(
|
||||||
:user,
|
:user, :job, { raid: :group },
|
||||||
:job,
|
{ characters: %i[character awakening] },
|
||||||
{ raid: :group },
|
{ weapons: {
|
||||||
{ characters: [:character, :awakening] },
|
weapon: [:awakenings],
|
||||||
{
|
awakening: {},
|
||||||
weapons: {
|
weapon_key1: {},
|
||||||
# Eager load the associated weapon and its awakenings.
|
weapon_key2: {},
|
||||||
weapon: [:awakenings],
|
weapon_key3: {}
|
||||||
# Eager load the grid weapon’s own awakening (if applicable).
|
}
|
||||||
awakening: {},
|
|
||||||
# Eager load any weapon key associations.
|
|
||||||
weapon_key1: {},
|
|
||||||
weapon_key2: {},
|
|
||||||
weapon_key3: {}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ summons: :summon },
|
{ summons: :summon },
|
||||||
:guidebook1,
|
:guidebook1, :guidebook2, :guidebook3,
|
||||||
:guidebook2,
|
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
|
||||||
:guidebook3,
|
|
||||||
:source_party,
|
|
||||||
:remixes,
|
|
||||||
:skill0,
|
|
||||||
:skill1,
|
|
||||||
:skill2,
|
|
||||||
:skill3,
|
|
||||||
:accessory
|
|
||||||
).find_by(shortcode: params[:id])
|
).find_by(shortcode: params[:id])
|
||||||
|
|
||||||
render_not_found_response('party') unless @party
|
render_not_found_response('party') unless @party
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads party by ID for update/destroy actions
|
# Loads the party by its id.
|
||||||
# @return [void]
|
|
||||||
def set
|
def set
|
||||||
@party = Party.where('id = ?', params[:id]).first
|
@party = Party.where('id = ?', params[:id]).first
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Parameter Sanitization
|
# Sanitizes and permits party parameters.
|
||||||
|
|
||||||
# Sanitizes and permits party parameters
|
|
||||||
# @return [Hash, nil] permitted parameters
|
|
||||||
def party_params
|
def party_params
|
||||||
return unless params[:party].present?
|
return unless params[:party].present?
|
||||||
|
|
||||||
params.require(:party).permit(
|
params.require(:party).permit(
|
||||||
:user_id,
|
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
|
||||||
:local_id,
|
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
|
||||||
:edit_key,
|
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
|
||||||
:extra,
|
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
|
||||||
:name,
|
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
|
||||||
:description,
|
:transcendence_step, :perpetuity, :awakening_id, :awakening_level,
|
||||||
:raid_id,
|
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||||
: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] }],
|
earring: %i[modifier strength] }],
|
||||||
summons_attributes: %i[id party_id summon_id position main friend
|
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
|
||||||
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]
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
35
app/controllers/concerns/party_authorization_concern.rb
Normal file
35
app/controllers/concerns/party_authorization_concern.rb
Normal file
|
|
@ -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
|
||||||
32
app/controllers/concerns/party_preview_concern.rb
Normal file
32
app/controllers/concerns/party_preview_concern.rb
Normal file
|
|
@ -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
|
||||||
164
app/controllers/concerns/party_querying_concern.rb
Normal file
164
app/controllers/concerns/party_querying_concern.rb
Normal file
|
|
@ -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
|
||||||
15
app/helpers/party_constants.rb
Normal file
15
app/helpers/party_constants.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -7,9 +7,7 @@ module PreviewService
|
||||||
PREVIEW_EXPIRY = 30.days
|
PREVIEW_EXPIRY = 30.days
|
||||||
GENERATION_TIMEOUT = 5.minutes
|
GENERATION_TIMEOUT = 5.minutes
|
||||||
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
||||||
|
|
||||||
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
||||||
PREVIEW_EXPIRY = 30.days
|
|
||||||
|
|
||||||
# Public Interface - Core Operations
|
# Public Interface - Core Operations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ Rails.application.routes.draw do
|
||||||
get 'parties/favorites', to: 'parties#favorites'
|
get 'parties/favorites', to: 'parties#favorites'
|
||||||
get 'parties/:id', to: 'parties#show'
|
get 'parties/:id', to: 'parties#show'
|
||||||
get 'parties/:id/preview', to: 'parties#preview'
|
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/regenerate_preview', to: 'parties#regenerate_preview'
|
||||||
post 'parties/:id/remix', to: 'parties#remix'
|
post 'parties/:id/remix', to: 'parties#remix'
|
||||||
|
|
||||||
|
|
|
||||||
134
spec/controllers/concerns/party_authorization_concern_spec.rb
Normal file
134
spec/controllers/concerns/party_authorization_concern_spec.rb
Normal file
|
|
@ -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
|
||||||
171
spec/controllers/concerns/party_querying_concern_spec.rb
Normal file
171
spec/controllers/concerns/party_querying_concern_spec.rb
Normal file
|
|
@ -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
|
||||||
11
spec/factories/favorites.rb
Normal file
11
spec/factories/favorites.rb
Normal file
|
|
@ -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
|
||||||
205
spec/requests/parties_controller_spec.rb
Normal file
205
spec/requests/parties_controller_spec.rb
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue