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:
Justin Edmund 2025-02-12 00:12:14 -08:00
parent ad9a6d7b5f
commit a1818ec4c6
12 changed files with 847 additions and 638 deletions

View file

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

View file

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

View 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

View 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

View 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 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 }
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

View 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

View file

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

View file

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

View 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

View 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

View 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

View 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