add GW events and scoring system
- create gw_events, crew_gw_participations, gw_crew_scores, gw_individual_scores - add models, blueprints, controllers for GW tracking - add model specs and gw_events controller specs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4d30363187
commit
f2a058b6b2
28 changed files with 1261 additions and 1 deletions
64
app/blueprints/api/v1/crew_gw_participation_blueprint.rb
Normal file
64
app/blueprints/api/v1/crew_gw_participation_blueprint.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class CrewGwParticipationBlueprint < ApiBlueprint
|
||||
fields :preliminary_ranking, :final_ranking
|
||||
|
||||
field :total_score do |participation|
|
||||
participation.total_crew_score
|
||||
end
|
||||
|
||||
field :wins do |participation|
|
||||
participation.wins_count
|
||||
end
|
||||
|
||||
field :losses do |participation|
|
||||
participation.losses_count
|
||||
end
|
||||
|
||||
view :summary do
|
||||
# summary uses base fields only (no gw_event)
|
||||
end
|
||||
|
||||
view :with_event do
|
||||
field :gw_event do |participation|
|
||||
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||
end
|
||||
end
|
||||
|
||||
view :with_crew do
|
||||
field :crew do |participation|
|
||||
CrewBlueprint.render_as_hash(participation.crew, view: :minimal)
|
||||
end
|
||||
field :gw_event do |participation|
|
||||
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||
end
|
||||
end
|
||||
|
||||
view :full do
|
||||
field :gw_event do |participation|
|
||||
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||
end
|
||||
field :crew_scores do |participation|
|
||||
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
|
||||
end
|
||||
end
|
||||
|
||||
view :with_individual_scores do
|
||||
field :gw_event do |participation|
|
||||
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||
end
|
||||
field :crew_scores do |participation|
|
||||
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
|
||||
end
|
||||
field :individual_scores do |participation|
|
||||
GwIndividualScoreBlueprint.render_as_hash(
|
||||
participation.gw_individual_scores.includes(:crew_membership).order(:round),
|
||||
view: :with_member
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
9
app/blueprints/api/v1/gw_crew_score_blueprint.rb
Normal file
9
app/blueprints/api/v1/gw_crew_score_blueprint.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwCrewScoreBlueprint < ApiBlueprint
|
||||
fields :round, :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id, :victory
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/blueprints/api/v1/gw_event_blueprint.rb
Normal file
25
app/blueprints/api/v1/gw_event_blueprint.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwEventBlueprint < ApiBlueprint
|
||||
fields :name, :element, :start_date, :end_date, :event_number
|
||||
|
||||
field :status do |event|
|
||||
if event.active?
|
||||
'active'
|
||||
elsif event.upcoming?
|
||||
'upcoming'
|
||||
else
|
||||
'finished'
|
||||
end
|
||||
end
|
||||
|
||||
view :with_participation do
|
||||
field :participation, if: ->(_fn, _obj, options) { options[:participation].present? } do |_, options|
|
||||
CrewGwParticipationBlueprint.render_as_hash(options[:participation], view: :summary)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
app/blueprints/api/v1/gw_individual_score_blueprint.rb
Normal file
17
app/blueprints/api/v1/gw_individual_score_blueprint.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwIndividualScoreBlueprint < ApiBlueprint
|
||||
fields :round, :score, :is_cumulative
|
||||
|
||||
view :with_member do
|
||||
field :member do |score|
|
||||
if score.crew_membership.present?
|
||||
CrewMembershipBlueprint.render_as_hash(score.crew_membership, view: :with_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
63
app/controllers/api/v1/crew_gw_participations_controller.rb
Normal file
63
app/controllers/api/v1/crew_gw_participations_controller.rb
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class CrewGwParticipationsController < Api::V1::ApiController
|
||||
include CrewAuthorizationConcern
|
||||
|
||||
before_action :restrict_access
|
||||
before_action :set_crew
|
||||
before_action :authorize_crew_member!
|
||||
before_action :set_participation, only: %i[show update]
|
||||
before_action :authorize_crew_officer!, only: %i[create update]
|
||||
|
||||
# GET /crew/gw_participations
|
||||
def index
|
||||
participations = @crew.crew_gw_participations.includes(:gw_event).order('gw_events.start_date DESC')
|
||||
render json: CrewGwParticipationBlueprint.render(participations, view: :with_event, root: :crew_gw_participations)
|
||||
end
|
||||
|
||||
# GET /crew/gw_participations/:id
|
||||
def show
|
||||
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_individual_scores, root: :crew_gw_participation)
|
||||
end
|
||||
|
||||
# POST /gw_events/:id/participations
|
||||
def create
|
||||
event = GwEvent.find(params[:id])
|
||||
|
||||
participation = @crew.crew_gw_participations.build(gw_event: event)
|
||||
|
||||
if participation.save
|
||||
render json: CrewGwParticipationBlueprint.render(participation, view: :with_event, root: :crew_gw_participation), status: :created
|
||||
else
|
||||
render_validation_error_response(participation)
|
||||
end
|
||||
end
|
||||
|
||||
# PUT /crew/gw_participations/:id
|
||||
def update
|
||||
if @participation.update(participation_params)
|
||||
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_event, root: :crew_gw_participation)
|
||||
else
|
||||
render_validation_error_response(@participation)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_crew
|
||||
@crew = current_user.crew
|
||||
raise CrewErrors::NotInCrewError unless @crew
|
||||
end
|
||||
|
||||
def set_participation
|
||||
@participation = @crew.crew_gw_participations.find(params[:id])
|
||||
end
|
||||
|
||||
def participation_params
|
||||
params.require(:crew_gw_participation).permit(:preliminary_ranking, :final_ranking)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
app/controllers/api/v1/gw_crew_scores_controller.rb
Normal file
60
app/controllers/api/v1/gw_crew_scores_controller.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwCrewScoresController < Api::V1::ApiController
|
||||
include CrewAuthorizationConcern
|
||||
|
||||
before_action :restrict_access
|
||||
before_action :set_crew
|
||||
before_action :authorize_crew_officer!
|
||||
before_action :set_participation
|
||||
before_action :set_score, only: %i[update destroy]
|
||||
|
||||
# POST /crew/gw_participations/:participation_id/crew_scores
|
||||
def create
|
||||
score = @participation.gw_crew_scores.build(score_params)
|
||||
|
||||
if score.save
|
||||
render json: GwCrewScoreBlueprint.render(score, root: :crew_score), status: :created
|
||||
else
|
||||
render_validation_error_response(score)
|
||||
end
|
||||
end
|
||||
|
||||
# PUT /crew/gw_participations/:participation_id/crew_scores/:id
|
||||
def update
|
||||
if @score.update(score_params)
|
||||
render json: GwCrewScoreBlueprint.render(@score, root: :crew_score)
|
||||
else
|
||||
render_validation_error_response(@score)
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /crew/gw_participations/:participation_id/crew_scores/:id
|
||||
def destroy
|
||||
@score.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_crew
|
||||
@crew = current_user.crew
|
||||
raise CrewErrors::NotInCrewError unless @crew
|
||||
end
|
||||
|
||||
def set_participation
|
||||
@participation = @crew.crew_gw_participations.find(params[:participation_id])
|
||||
end
|
||||
|
||||
def set_score
|
||||
@score = @participation.gw_crew_scores.find(params[:id])
|
||||
end
|
||||
|
||||
def score_params
|
||||
params.require(:crew_score).permit(:round, :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
57
app/controllers/api/v1/gw_events_controller.rb
Normal file
57
app/controllers/api/v1/gw_events_controller.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwEventsController < Api::V1::ApiController
|
||||
before_action :restrict_access, only: %i[create update]
|
||||
before_action :require_admin!, only: %i[create update]
|
||||
before_action :set_event, only: %i[show update]
|
||||
|
||||
# GET /gw_events
|
||||
def index
|
||||
events = GwEvent.order(start_date: :desc)
|
||||
render json: GwEventBlueprint.render(events, root: :gw_events)
|
||||
end
|
||||
|
||||
# GET /gw_events/:id
|
||||
def show
|
||||
participation = current_user&.crew&.crew_gw_participations&.find_by(gw_event: @event)
|
||||
render json: GwEventBlueprint.render(@event, view: :with_participation, participation: participation, root: :gw_event)
|
||||
end
|
||||
|
||||
# POST /gw_events (admin only)
|
||||
def create
|
||||
event = GwEvent.new(event_params)
|
||||
|
||||
if event.save
|
||||
render json: GwEventBlueprint.render(event, root: :gw_event), status: :created
|
||||
else
|
||||
render_validation_error_response(event)
|
||||
end
|
||||
end
|
||||
|
||||
# PUT /gw_events/:id (admin only)
|
||||
def update
|
||||
if @event.update(event_params)
|
||||
render json: GwEventBlueprint.render(@event, root: :gw_event)
|
||||
else
|
||||
render_validation_error_response(@event)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = GwEvent.find(params[:id])
|
||||
end
|
||||
|
||||
def event_params
|
||||
params.require(:gw_event).permit(:name, :element, :start_date, :end_date, :event_number)
|
||||
end
|
||||
|
||||
def require_admin!
|
||||
raise Api::V1::UnauthorizedError unless current_user&.admin?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
116
app/controllers/api/v1/gw_individual_scores_controller.rb
Normal file
116
app/controllers/api/v1/gw_individual_scores_controller.rb
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class GwIndividualScoresController < Api::V1::ApiController
|
||||
include CrewAuthorizationConcern
|
||||
|
||||
before_action :restrict_access
|
||||
before_action :set_crew
|
||||
before_action :authorize_crew_member!
|
||||
before_action :set_participation
|
||||
before_action :set_score, only: %i[update destroy]
|
||||
|
||||
# POST /crew/gw_participations/:participation_id/individual_scores
|
||||
def create
|
||||
# Members can only record their own scores, officers can record anyone's
|
||||
membership_id = score_params[:crew_membership_id]
|
||||
unless can_record_score_for?(membership_id)
|
||||
raise Api::V1::UnauthorizedError
|
||||
end
|
||||
|
||||
score = @participation.gw_individual_scores.build(score_params)
|
||||
score.recorded_by = current_user
|
||||
|
||||
if score.save
|
||||
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score), status: :created
|
||||
else
|
||||
render_validation_error_response(score)
|
||||
end
|
||||
end
|
||||
|
||||
# PUT /crew/gw_participations/:participation_id/individual_scores/:id
|
||||
def update
|
||||
unless can_record_score_for?(@score.crew_membership_id)
|
||||
raise Api::V1::UnauthorizedError
|
||||
end
|
||||
|
||||
if @score.update(score_params.except(:crew_membership_id))
|
||||
render json: GwIndividualScoreBlueprint.render(@score, view: :with_member, root: :individual_score)
|
||||
else
|
||||
render_validation_error_response(@score)
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /crew/gw_participations/:participation_id/individual_scores/:id
|
||||
def destroy
|
||||
unless can_record_score_for?(@score.crew_membership_id)
|
||||
raise Api::V1::UnauthorizedError
|
||||
end
|
||||
|
||||
@score.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
# POST /crew/gw_participations/:participation_id/individual_scores/batch
|
||||
def batch
|
||||
authorize_crew_officer!
|
||||
|
||||
scores_params = params.require(:scores)
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
scores_params.each_with_index do |score_data, index|
|
||||
score = @participation.gw_individual_scores.find_or_initialize_by(
|
||||
crew_membership_id: score_data[:crew_membership_id],
|
||||
round: score_data[:round]
|
||||
)
|
||||
score.assign_attributes(
|
||||
score: score_data[:score],
|
||||
is_cumulative: score_data[:is_cumulative] || false,
|
||||
recorded_by: current_user
|
||||
)
|
||||
|
||||
if score.save
|
||||
results << score
|
||||
else
|
||||
errors << { index: index, errors: score.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
if errors.empty?
|
||||
render json: GwIndividualScoreBlueprint.render(results, view: :with_member, root: :individual_scores), status: :created
|
||||
else
|
||||
render json: { individual_scores: GwIndividualScoreBlueprint.render_as_hash(results, view: :with_member), errors: errors },
|
||||
status: :multi_status
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_crew
|
||||
@crew = current_user.crew
|
||||
raise CrewErrors::NotInCrewError unless @crew
|
||||
end
|
||||
|
||||
def set_participation
|
||||
@participation = @crew.crew_gw_participations.find(params[:participation_id])
|
||||
end
|
||||
|
||||
def set_score
|
||||
@score = @participation.gw_individual_scores.find(params[:id])
|
||||
end
|
||||
|
||||
def score_params
|
||||
params.require(:individual_score).permit(:crew_membership_id, :round, :score, :is_cumulative)
|
||||
end
|
||||
|
||||
def can_record_score_for?(membership_id)
|
||||
return true if current_user.crew_officer?
|
||||
|
||||
# Regular members can only record their own scores
|
||||
current_user.active_crew_membership&.id == membership_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,6 +7,8 @@ class Crew < ApplicationRecord
|
|||
has_many :active_members, through: :active_memberships, source: :user
|
||||
has_many :crew_invitations, dependent: :destroy
|
||||
has_many :pending_invitations, -> { where(status: :pending) }, class_name: 'CrewInvitation'
|
||||
has_many :crew_gw_participations, dependent: :destroy
|
||||
has_many :gw_events, through: :crew_gw_participations
|
||||
|
||||
validates :name, presence: true, length: { maximum: 100 }
|
||||
validates :gamertag, length: { maximum: 50 }, allow_nil: true
|
||||
|
|
|
|||
39
app/models/crew_gw_participation.rb
Normal file
39
app/models/crew_gw_participation.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CrewGwParticipation < ApplicationRecord
|
||||
belongs_to :crew
|
||||
belongs_to :gw_event
|
||||
|
||||
has_many :gw_crew_scores, dependent: :destroy
|
||||
has_many :gw_individual_scores, dependent: :destroy
|
||||
|
||||
validates :crew_id, uniqueness: { scope: :gw_event_id, message: 'is already participating in this event' }
|
||||
|
||||
# Get total crew score across all rounds
|
||||
def total_crew_score
|
||||
gw_crew_scores.sum(:crew_score)
|
||||
end
|
||||
|
||||
# Get wins count
|
||||
def wins_count
|
||||
gw_crew_scores.where(victory: true).count
|
||||
end
|
||||
|
||||
# Get losses count
|
||||
def losses_count
|
||||
gw_crew_scores.where(victory: false).count
|
||||
end
|
||||
|
||||
# Get individual scores for a specific round
|
||||
def individual_scores_for_round(round)
|
||||
gw_individual_scores.where(round: round).includes(:crew_membership)
|
||||
end
|
||||
|
||||
# Get leaderboard - members ranked by total score
|
||||
def leaderboard
|
||||
gw_individual_scores
|
||||
.select('crew_membership_id, SUM(score) as total_score')
|
||||
.group(:crew_membership_id)
|
||||
.order('total_score DESC')
|
||||
end
|
||||
end
|
||||
34
app/models/gw_crew_score.rb
Normal file
34
app/models/gw_crew_score.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GwCrewScore < ApplicationRecord
|
||||
belongs_to :crew_gw_participation
|
||||
|
||||
# Rounds: 0=prelims, 1=interlude, 2-5=finals day 1-4
|
||||
ROUNDS = {
|
||||
preliminaries: 0,
|
||||
interlude: 1,
|
||||
finals_day_1: 2,
|
||||
finals_day_2: 3,
|
||||
finals_day_3: 4,
|
||||
finals_day_4: 5
|
||||
}.freeze
|
||||
|
||||
enum :round, ROUNDS
|
||||
|
||||
validates :round, presence: true
|
||||
validates :crew_score, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :opponent_score, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
||||
validates :round, uniqueness: { scope: :crew_gw_participation_id }
|
||||
|
||||
before_save :determine_victory
|
||||
|
||||
delegate :crew, :gw_event, to: :crew_gw_participation
|
||||
|
||||
private
|
||||
|
||||
def determine_victory
|
||||
return if opponent_score.nil?
|
||||
|
||||
self.victory = crew_score > opponent_score
|
||||
end
|
||||
end
|
||||
42
app/models/gw_event.rb
Normal file
42
app/models/gw_event.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GwEvent < ApplicationRecord
|
||||
include GranblueEnums
|
||||
|
||||
has_many :crew_gw_participations, dependent: :destroy
|
||||
has_many :crews, through: :crew_gw_participations
|
||||
|
||||
enum :element, ELEMENTS
|
||||
|
||||
validates :name, presence: true
|
||||
validates :element, presence: true
|
||||
validates :start_date, presence: true
|
||||
validates :end_date, presence: true
|
||||
validates :event_number, presence: true, uniqueness: true
|
||||
|
||||
validate :end_date_after_start_date
|
||||
|
||||
scope :upcoming, -> { where('start_date > ?', Date.current).order(start_date: :asc) }
|
||||
scope :past, -> { where('end_date < ?', Date.current).order(start_date: :desc) }
|
||||
scope :current, -> { where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
|
||||
|
||||
def active?
|
||||
start_date <= Date.current && end_date >= Date.current
|
||||
end
|
||||
|
||||
def upcoming?
|
||||
start_date > Date.current
|
||||
end
|
||||
|
||||
def finished?
|
||||
end_date < Date.current
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return unless start_date.present? && end_date.present?
|
||||
|
||||
errors.add(:end_date, 'must be after start date') if end_date < start_date
|
||||
end
|
||||
end
|
||||
34
app/models/gw_individual_score.rb
Normal file
34
app/models/gw_individual_score.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GwIndividualScore < ApplicationRecord
|
||||
belongs_to :crew_gw_participation
|
||||
belongs_to :crew_membership, optional: true
|
||||
belongs_to :recorded_by, class_name: 'User'
|
||||
|
||||
# Use same round enum as GwCrewScore
|
||||
enum :round, GwCrewScore::ROUNDS
|
||||
|
||||
validates :round, presence: true
|
||||
validates :score, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :crew_membership_id, uniqueness: {
|
||||
scope: %i[crew_gw_participation_id round],
|
||||
message: 'already has a score for this round'
|
||||
}, if: -> { crew_membership_id.present? }
|
||||
|
||||
validate :membership_belongs_to_crew
|
||||
|
||||
delegate :crew, :gw_event, to: :crew_gw_participation
|
||||
|
||||
scope :for_round, ->(round) { where(round: round) }
|
||||
scope :for_membership, ->(membership) { where(crew_membership: membership) }
|
||||
|
||||
private
|
||||
|
||||
def membership_belongs_to_crew
|
||||
return unless crew_membership.present?
|
||||
|
||||
unless crew_membership.crew_id == crew_gw_participation.crew_id
|
||||
errors.add(:crew_membership, 'must belong to the participating crew')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -193,6 +193,25 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
# GW Events (public read, admin write)
|
||||
resources :gw_events, only: %i[index show create update] do
|
||||
member do
|
||||
post :participations, to: 'crew_gw_participations#create'
|
||||
end
|
||||
end
|
||||
|
||||
# Current user's crew GW participations
|
||||
scope :crew do
|
||||
resources :gw_participations, controller: 'crew_gw_participations', only: %i[index show update] do
|
||||
resources :crew_scores, controller: 'gw_crew_scores', only: %i[create update destroy]
|
||||
resources :individual_scores, controller: 'gw_individual_scores', only: %i[create update destroy] do
|
||||
collection do
|
||||
post :batch
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Reading collections - works for any user with privacy check
|
||||
scope 'users/:user_id' do
|
||||
namespace :collection do
|
||||
|
|
|
|||
18
db/migrate/20251204070958_create_gw_events.rb
Normal file
18
db/migrate/20251204070958_create_gw_events.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateGwEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gw_events, id: :uuid do |t|
|
||||
t.string :name, null: false
|
||||
t.integer :element, null: false
|
||||
t.date :start_date, null: false
|
||||
t.date :end_date, null: false
|
||||
t.integer :event_number, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :gw_events, :event_number, unique: true
|
||||
add_index :gw_events, :start_date
|
||||
end
|
||||
end
|
||||
16
db/migrate/20251204070959_create_crew_gw_participations.rb
Normal file
16
db/migrate/20251204070959_create_crew_gw_participations.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateCrewGwParticipations < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :crew_gw_participations, id: :uuid do |t|
|
||||
t.references :crew, type: :uuid, null: false, foreign_key: true
|
||||
t.references :gw_event, type: :uuid, null: false, foreign_key: true
|
||||
t.bigint :preliminary_ranking
|
||||
t.bigint :final_ranking
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :crew_gw_participations, %i[crew_id gw_event_id], unique: true
|
||||
end
|
||||
end
|
||||
19
db/migrate/20251204071000_create_gw_crew_scores.rb
Normal file
19
db/migrate/20251204071000_create_gw_crew_scores.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateGwCrewScores < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gw_crew_scores, id: :uuid do |t|
|
||||
t.references :crew_gw_participation, type: :uuid, null: false, foreign_key: true
|
||||
t.integer :round, null: false, comment: '0=prelims, 1=interlude, 2-5=finals day 1-4'
|
||||
t.bigint :crew_score, default: 0, null: false
|
||||
t.bigint :opponent_score
|
||||
t.string :opponent_name
|
||||
t.string :opponent_granblue_id
|
||||
t.boolean :victory
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :gw_crew_scores, %i[crew_gw_participation_id round], unique: true
|
||||
end
|
||||
end
|
||||
20
db/migrate/20251204071001_create_gw_individual_scores.rb
Normal file
20
db/migrate/20251204071001_create_gw_individual_scores.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateGwIndividualScores < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gw_individual_scores, id: :uuid do |t|
|
||||
t.references :crew_gw_participation, type: :uuid, null: false, foreign_key: true
|
||||
t.references :crew_membership, type: :uuid, null: true, foreign_key: true
|
||||
t.integer :round, null: false
|
||||
t.bigint :score, default: 0, null: false
|
||||
t.boolean :is_cumulative, default: false, null: false
|
||||
t.references :recorded_by, type: :uuid, null: false, foreign_key: { to_table: :users }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :gw_individual_scores, %i[crew_gw_participation_id crew_membership_id round],
|
||||
unique: true,
|
||||
name: 'idx_gw_individual_scores_unique'
|
||||
end
|
||||
end
|
||||
61
db/schema.rb
61
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_04_071001) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
|
@ -234,6 +234,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) do
|
|||
t.index ["weapon_key4_id"], name: "index_collection_weapons_on_weapon_key4_id"
|
||||
end
|
||||
|
||||
create_table "crew_gw_participations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "crew_id", null: false
|
||||
t.uuid "gw_event_id", null: false
|
||||
t.bigint "preliminary_ranking"
|
||||
t.bigint "final_ranking"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["crew_id", "gw_event_id"], name: "index_crew_gw_participations_on_crew_id_and_gw_event_id", unique: true
|
||||
t.index ["crew_id"], name: "index_crew_gw_participations_on_crew_id"
|
||||
t.index ["gw_event_id"], name: "index_crew_gw_participations_on_gw_event_id"
|
||||
end
|
||||
|
||||
create_table "crew_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "crew_id", null: false
|
||||
t.uuid "user_id", null: false, comment: "Invitee"
|
||||
|
|
@ -435,6 +447,47 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) do
|
|||
t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
end
|
||||
|
||||
create_table "gw_crew_scores", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "crew_gw_participation_id", null: false
|
||||
t.integer "round", null: false, comment: "0=prelims, 1=interlude, 2-5=finals day 1-4"
|
||||
t.bigint "crew_score", default: 0, null: false
|
||||
t.bigint "opponent_score"
|
||||
t.string "opponent_name"
|
||||
t.string "opponent_granblue_id"
|
||||
t.boolean "victory"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["crew_gw_participation_id", "round"], name: "index_gw_crew_scores_on_crew_gw_participation_id_and_round", unique: true
|
||||
t.index ["crew_gw_participation_id"], name: "index_gw_crew_scores_on_crew_gw_participation_id"
|
||||
end
|
||||
|
||||
create_table "gw_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.integer "element", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "event_number", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["event_number"], name: "index_gw_events_on_event_number", unique: true
|
||||
t.index ["start_date"], name: "index_gw_events_on_start_date"
|
||||
end
|
||||
|
||||
create_table "gw_individual_scores", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "crew_gw_participation_id", null: false
|
||||
t.uuid "crew_membership_id"
|
||||
t.integer "round", null: false
|
||||
t.bigint "score", default: 0, null: false
|
||||
t.boolean "is_cumulative", default: false, null: false
|
||||
t.uuid "recorded_by_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["crew_gw_participation_id", "crew_membership_id", "round"], name: "idx_gw_individual_scores_unique", unique: true
|
||||
t.index ["crew_gw_participation_id"], name: "index_gw_individual_scores_on_crew_gw_participation_id"
|
||||
t.index ["crew_membership_id"], name: "index_gw_individual_scores_on_crew_membership_id"
|
||||
t.index ["recorded_by_id"], name: "index_gw_individual_scores_on_recorded_by_id"
|
||||
end
|
||||
|
||||
create_table "job_accessories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "job_id"
|
||||
t.string "name_en", null: false
|
||||
|
|
@ -897,6 +950,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) do
|
|||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id"
|
||||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id"
|
||||
add_foreign_key "collection_weapons", "weapons"
|
||||
add_foreign_key "crew_gw_participations", "crews"
|
||||
add_foreign_key "crew_gw_participations", "gw_events"
|
||||
add_foreign_key "crew_invitations", "crews"
|
||||
add_foreign_key "crew_invitations", "users"
|
||||
add_foreign_key "crew_invitations", "users", column: "invited_by_id"
|
||||
|
|
@ -920,6 +975,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) do
|
|||
add_foreign_key "grid_weapons", "parties"
|
||||
add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id"
|
||||
add_foreign_key "grid_weapons", "weapons"
|
||||
add_foreign_key "gw_crew_scores", "crew_gw_participations"
|
||||
add_foreign_key "gw_individual_scores", "crew_gw_participations"
|
||||
add_foreign_key "gw_individual_scores", "crew_memberships"
|
||||
add_foreign_key "gw_individual_scores", "users", column: "recorded_by_id"
|
||||
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "parties", "guidebooks", column: "guidebook1_id"
|
||||
|
|
|
|||
8
spec/factories/crew_gw_participations.rb
Normal file
8
spec/factories/crew_gw_participations.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FactoryBot.define do
|
||||
factory :crew_gw_participation do
|
||||
crew
|
||||
gw_event
|
||||
preliminary_ranking { nil }
|
||||
final_ranking { nil }
|
||||
end
|
||||
end
|
||||
29
spec/factories/gw_crew_scores.rb
Normal file
29
spec/factories/gw_crew_scores.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
FactoryBot.define do
|
||||
factory :gw_crew_score do
|
||||
crew_gw_participation
|
||||
round { :preliminaries }
|
||||
crew_score { Faker::Number.between(from: 100_000, to: 10_000_000) }
|
||||
opponent_score { nil }
|
||||
opponent_name { nil }
|
||||
opponent_granblue_id { nil }
|
||||
victory { nil }
|
||||
|
||||
trait :with_opponent do
|
||||
opponent_score { Faker::Number.between(from: 100_000, to: 10_000_000) }
|
||||
opponent_name { Faker::Team.name }
|
||||
opponent_granblue_id { Faker::Number.number(digits: 8).to_s }
|
||||
end
|
||||
|
||||
trait :victory do
|
||||
with_opponent
|
||||
crew_score { 10_000_000 }
|
||||
opponent_score { 5_000_000 }
|
||||
end
|
||||
|
||||
trait :defeat do
|
||||
with_opponent
|
||||
crew_score { 5_000_000 }
|
||||
opponent_score { 10_000_000 }
|
||||
end
|
||||
end
|
||||
end
|
||||
24
spec/factories/gw_events.rb
Normal file
24
spec/factories/gw_events.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FactoryBot.define do
|
||||
factory :gw_event do
|
||||
sequence(:name) { |n| "Unite and Fight ##{n}" }
|
||||
element { %i[Fire Water Earth Wind Light Dark].sample }
|
||||
start_date { 1.week.from_now.to_date }
|
||||
end_date { 2.weeks.from_now.to_date }
|
||||
sequence(:event_number) { |n| n }
|
||||
|
||||
trait :active do
|
||||
start_date { 2.days.ago.to_date }
|
||||
end_date { 5.days.from_now.to_date }
|
||||
end
|
||||
|
||||
trait :finished do
|
||||
start_date { 3.weeks.ago.to_date }
|
||||
end_date { 2.weeks.ago.to_date }
|
||||
end
|
||||
|
||||
trait :upcoming do
|
||||
start_date { 1.week.from_now.to_date }
|
||||
end_date { 2.weeks.from_now.to_date }
|
||||
end
|
||||
end
|
||||
end
|
||||
10
spec/factories/gw_individual_scores.rb
Normal file
10
spec/factories/gw_individual_scores.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FactoryBot.define do
|
||||
factory :gw_individual_score do
|
||||
crew_gw_participation
|
||||
crew_membership
|
||||
round { :preliminaries }
|
||||
score { Faker::Number.between(from: 10_000, to: 1_000_000) }
|
||||
is_cumulative { false }
|
||||
association :recorded_by, factory: :user
|
||||
end
|
||||
end
|
||||
82
spec/models/crew_gw_participation_spec.rb
Normal file
82
spec/models/crew_gw_participation_spec.rb
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CrewGwParticipation, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:crew) }
|
||||
it { is_expected.to belong_to(:gw_event) }
|
||||
it { is_expected.to have_many(:gw_crew_scores).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:gw_individual_scores).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
let!(:crew) { create(:crew) }
|
||||
let!(:gw_event) { create(:gw_event) }
|
||||
let!(:existing_participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) }
|
||||
|
||||
it 'requires unique crew and gw_event combination' do
|
||||
duplicate = build(:crew_gw_participation, crew: crew, gw_event: gw_event)
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:crew_id]).to include('is already participating in this event')
|
||||
end
|
||||
|
||||
it 'allows same crew in different events' do
|
||||
other_event = create(:gw_event)
|
||||
participation = build(:crew_gw_participation, crew: crew, gw_event: other_event)
|
||||
expect(participation).to be_valid
|
||||
end
|
||||
|
||||
it 'allows different crews in same event' do
|
||||
other_crew = create(:crew)
|
||||
participation = build(:crew_gw_participation, crew: other_crew, gw_event: gw_event)
|
||||
expect(participation).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_crew_score' do
|
||||
let(:participation) { create(:crew_gw_participation) }
|
||||
|
||||
context 'with no scores' do
|
||||
it 'returns 0' do
|
||||
expect(participation.total_crew_score).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with scores' do
|
||||
before do
|
||||
create(:gw_crew_score, crew_gw_participation: participation, round: :preliminaries, crew_score: 1_000_000)
|
||||
create(:gw_crew_score, crew_gw_participation: participation, round: :finals_day_1, crew_score: 2_000_000)
|
||||
end
|
||||
|
||||
it 'returns the sum of all crew scores' do
|
||||
expect(participation.total_crew_score).to eq(3_000_000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#wins_count and #losses_count' do
|
||||
let(:participation) { create(:crew_gw_participation) }
|
||||
|
||||
context 'with no battles' do
|
||||
it 'returns 0 for both' do
|
||||
expect(participation.wins_count).to eq(0)
|
||||
expect(participation.losses_count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with battles' do
|
||||
before do
|
||||
create(:gw_crew_score, :victory, crew_gw_participation: participation, round: :finals_day_1)
|
||||
create(:gw_crew_score, :victory, crew_gw_participation: participation, round: :finals_day_2)
|
||||
create(:gw_crew_score, :defeat, crew_gw_participation: participation, round: :finals_day_3)
|
||||
end
|
||||
|
||||
it 'returns correct win count' do
|
||||
expect(participation.wins_count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns correct loss count' do
|
||||
expect(participation.losses_count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
77
spec/models/gw_crew_score_spec.rb
Normal file
77
spec/models/gw_crew_score_spec.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GwCrewScore, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:crew_gw_participation) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:round) }
|
||||
it { is_expected.to validate_presence_of(:crew_score) }
|
||||
it { is_expected.to validate_numericality_of(:crew_score).is_greater_than_or_equal_to(0) }
|
||||
it { is_expected.to validate_numericality_of(:opponent_score).is_greater_than_or_equal_to(0).allow_nil }
|
||||
|
||||
describe 'round uniqueness' do
|
||||
let!(:participation) { create(:crew_gw_participation) }
|
||||
let!(:existing_score) { create(:gw_crew_score, crew_gw_participation: participation, round: :preliminaries) }
|
||||
|
||||
it 'requires unique round per participation' do
|
||||
duplicate = build(:gw_crew_score, crew_gw_participation: participation, round: :preliminaries)
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:round]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows same round in different participations' do
|
||||
other_participation = create(:crew_gw_participation)
|
||||
score = build(:gw_crew_score, crew_gw_participation: other_participation, round: :preliminaries)
|
||||
expect(score).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'round enum' do
|
||||
it 'has expected round values' do
|
||||
expect(GwCrewScore.rounds.keys).to contain_exactly(
|
||||
'preliminaries', 'interlude', 'finals_day_1', 'finals_day_2', 'finals_day_3', 'finals_day_4'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#determine_victory callback' do
|
||||
let(:participation) { create(:crew_gw_participation) }
|
||||
|
||||
context 'without opponent score' do
|
||||
it 'leaves victory nil' do
|
||||
score = create(:gw_crew_score, crew_gw_participation: participation, crew_score: 1_000_000, opponent_score: nil)
|
||||
expect(score.victory).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with opponent score' do
|
||||
it 'sets victory to true when crew wins' do
|
||||
score = create(:gw_crew_score, :with_opponent, crew_gw_participation: participation, crew_score: 10_000_000, opponent_score: 5_000_000)
|
||||
expect(score.victory).to be true
|
||||
end
|
||||
|
||||
it 'sets victory to false when crew loses' do
|
||||
score = create(:gw_crew_score, :with_opponent, crew_gw_participation: participation, crew_score: 5_000_000, opponent_score: 10_000_000)
|
||||
expect(score.victory).to be false
|
||||
end
|
||||
|
||||
it 'sets victory to false on tie' do
|
||||
score = create(:gw_crew_score, :with_opponent, crew_gw_participation: participation, crew_score: 5_000_000, opponent_score: 5_000_000)
|
||||
expect(score.victory).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when updating scores' do
|
||||
it 'recalculates victory on update' do
|
||||
score = create(:gw_crew_score, :victory, crew_gw_participation: participation)
|
||||
expect(score.victory).to be true
|
||||
|
||||
score.update!(crew_score: 1_000, opponent_score: 10_000_000)
|
||||
expect(score.victory).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
110
spec/models/gw_event_spec.rb
Normal file
110
spec/models/gw_event_spec.rb
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GwEvent, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to have_many(:crew_gw_participations).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:crews).through(:crew_gw_participations) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:element) }
|
||||
it { is_expected.to validate_presence_of(:start_date) }
|
||||
it { is_expected.to validate_presence_of(:end_date) }
|
||||
it { is_expected.to validate_presence_of(:event_number) }
|
||||
|
||||
describe 'event_number uniqueness' do
|
||||
let!(:existing_event) { create(:gw_event, event_number: 100) }
|
||||
|
||||
it 'requires unique event_number' do
|
||||
new_event = build(:gw_event, event_number: 100)
|
||||
expect(new_event).not_to be_valid
|
||||
expect(new_event.errors[:event_number]).to include('has already been taken')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'end_date_after_start_date' do
|
||||
it 'is invalid when end_date is before start_date' do
|
||||
event = build(:gw_event, start_date: Date.new(2025, 1, 15), end_date: Date.new(2025, 1, 10))
|
||||
expect(event).not_to be_valid
|
||||
expect(event.errors[:end_date]).to include('must be after start date')
|
||||
end
|
||||
|
||||
it 'is valid when end_date is same as start_date' do
|
||||
event = build(:gw_event, start_date: Date.today, end_date: Date.today)
|
||||
expect(event).to be_valid
|
||||
end
|
||||
|
||||
it 'is valid when end_date is after start_date' do
|
||||
event = build(:gw_event, start_date: Date.today, end_date: Date.tomorrow)
|
||||
expect(event).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:upcoming_event) { create(:gw_event, :upcoming) }
|
||||
let!(:active_event) { create(:gw_event, :active) }
|
||||
let!(:finished_event) { create(:gw_event, :finished) }
|
||||
|
||||
describe '.upcoming' do
|
||||
it 'returns only upcoming events' do
|
||||
expect(GwEvent.upcoming).to include(upcoming_event)
|
||||
expect(GwEvent.upcoming).not_to include(active_event)
|
||||
expect(GwEvent.upcoming).not_to include(finished_event)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.current' do
|
||||
it 'returns only active events' do
|
||||
expect(GwEvent.current).to include(active_event)
|
||||
expect(GwEvent.current).not_to include(upcoming_event)
|
||||
expect(GwEvent.current).not_to include(finished_event)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.past' do
|
||||
it 'returns only finished events' do
|
||||
expect(GwEvent.past).to include(finished_event)
|
||||
expect(GwEvent.past).not_to include(upcoming_event)
|
||||
expect(GwEvent.past).not_to include(active_event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for active event' do
|
||||
event = build(:gw_event, :active)
|
||||
expect(event.active?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for upcoming event' do
|
||||
event = build(:gw_event, :upcoming)
|
||||
expect(event.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#upcoming?' do
|
||||
it 'returns true for upcoming event' do
|
||||
event = build(:gw_event, :upcoming)
|
||||
expect(event.upcoming?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for active event' do
|
||||
event = build(:gw_event, :active)
|
||||
expect(event.upcoming?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#finished?' do
|
||||
it 'returns true for finished event' do
|
||||
event = build(:gw_event, :finished)
|
||||
expect(event.finished?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for active event' do
|
||||
event = build(:gw_event, :active)
|
||||
expect(event.finished?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
93
spec/models/gw_individual_score_spec.rb
Normal file
93
spec/models/gw_individual_score_spec.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GwIndividualScore, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:crew_gw_participation) }
|
||||
it { is_expected.to belong_to(:crew_membership).optional }
|
||||
it { is_expected.to belong_to(:recorded_by).class_name('User') }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:round) }
|
||||
it { is_expected.to validate_numericality_of(:score).is_greater_than_or_equal_to(0) }
|
||||
|
||||
describe 'round uniqueness per player' do
|
||||
let!(:participation) { create(:crew_gw_participation) }
|
||||
let!(:membership) { create(:crew_membership, crew: participation.crew) }
|
||||
let!(:existing_score) do
|
||||
create(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership,
|
||||
round: :preliminaries)
|
||||
end
|
||||
|
||||
it 'requires unique round per membership and participation' do
|
||||
duplicate = build(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership,
|
||||
round: :preliminaries)
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:crew_membership_id]).to include('already has a score for this round')
|
||||
end
|
||||
|
||||
it 'allows same round for different members' do
|
||||
other_membership = create(:crew_membership, crew: participation.crew)
|
||||
score = build(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: other_membership,
|
||||
round: :preliminaries)
|
||||
expect(score).to be_valid
|
||||
end
|
||||
|
||||
it 'allows same member in different rounds' do
|
||||
score = build(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership,
|
||||
round: :finals_day_1)
|
||||
expect(score).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'round enum' do
|
||||
it 'has expected round values' do
|
||||
expect(GwIndividualScore.rounds.keys).to contain_exactly(
|
||||
'preliminaries', 'interlude', 'finals_day_1', 'finals_day_2', 'finals_day_3', 'finals_day_4'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'is_cumulative flag' do
|
||||
let(:participation) { create(:crew_gw_participation) }
|
||||
let(:membership) { create(:crew_membership, crew: participation.crew) }
|
||||
|
||||
it 'defaults to false from factory' do
|
||||
score = create(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership)
|
||||
expect(score.is_cumulative).to be false
|
||||
end
|
||||
|
||||
it 'can be set to true' do
|
||||
score = create(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership,
|
||||
is_cumulative: true)
|
||||
expect(score.is_cumulative).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'recorded_by association' do
|
||||
let(:participation) { create(:crew_gw_participation) }
|
||||
let(:membership) { create(:crew_membership, crew: participation.crew) }
|
||||
let(:recorder) { create(:user) }
|
||||
|
||||
it 'tracks who recorded the score' do
|
||||
score = create(:gw_individual_score,
|
||||
crew_gw_participation: participation,
|
||||
crew_membership: membership,
|
||||
recorded_by: recorder)
|
||||
expect(score.recorded_by).to eq(recorder)
|
||||
end
|
||||
end
|
||||
end
|
||||
114
spec/requests/api/v1/gw_events_spec.rb
Normal file
114
spec/requests/api/v1/gw_events_spec.rb
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::GwEvents', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:admin) { create(:user, role: 9) }
|
||||
let(:access_token) do
|
||||
Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public')
|
||||
end
|
||||
let(:admin_token) do
|
||||
Doorkeeper::AccessToken.create!(resource_owner_id: admin.id, expires_in: 30.days, scopes: 'public')
|
||||
end
|
||||
let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } }
|
||||
let(:admin_headers) { { 'Authorization' => "Bearer #{admin_token.token}" } }
|
||||
|
||||
describe 'GET /api/v1/gw_events' do
|
||||
let!(:upcoming_event) { create(:gw_event, :upcoming) }
|
||||
let!(:active_event) { create(:gw_event, :active) }
|
||||
let!(:finished_event) { create(:gw_event, :finished) }
|
||||
|
||||
it 'returns all events' do
|
||||
get '/api/v1/gw_events'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response['gw_events'].length).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/gw_events/:id' do
|
||||
let!(:event) { create(:gw_event) }
|
||||
|
||||
it 'returns the event' do
|
||||
get "/api/v1/gw_events/#{event.id}"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response['gw_event']['id']).to eq(event.id)
|
||||
expect(json_response['gw_event']['name']).to eq(event.name)
|
||||
expect(json_response['gw_event']['element']).to eq(event.element)
|
||||
end
|
||||
|
||||
it 'returns 404 for non-existent event' do
|
||||
get '/api/v1/gw_events/00000000-0000-0000-0000-000000000000'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/gw_events' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
gw_event: {
|
||||
name: 'Unite and Fight #50',
|
||||
element: 'Fire',
|
||||
start_date: 1.week.from_now.to_date,
|
||||
end_date: 2.weeks.from_now.to_date,
|
||||
event_number: 50
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'as admin' do
|
||||
it 'creates a new event' do
|
||||
expect {
|
||||
post '/api/v1/gw_events', params: valid_params, headers: admin_headers
|
||||
}.to change(GwEvent, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(json_response['gw_event']['name']).to eq('Unite and Fight #50')
|
||||
expect(json_response['gw_event']['element']).to eq('Fire')
|
||||
end
|
||||
|
||||
it 'returns errors for invalid params' do
|
||||
post '/api/v1/gw_events', params: { gw_event: { name: '' } }, headers: admin_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as regular user' do
|
||||
it 'returns unauthorized' do
|
||||
post '/api/v1/gw_events', params: valid_params, headers: auth_headers
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without authentication' do
|
||||
it 'returns unauthorized' do
|
||||
post '/api/v1/gw_events', params: valid_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/gw_events/:id' do
|
||||
let!(:event) { create(:gw_event) }
|
||||
let(:update_params) { { gw_event: { name: 'Updated Event Name' } } }
|
||||
|
||||
context 'as admin' do
|
||||
it 'updates the event' do
|
||||
put "/api/v1/gw_events/#{event.id}", params: update_params, headers: admin_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response['gw_event']['name']).to eq('Updated Event Name')
|
||||
end
|
||||
end
|
||||
|
||||
context 'as regular user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/gw_events/#{event.id}", params: update_params, headers: auth_headers
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue