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:
Justin Edmund 2025-12-03 23:34:54 -08:00
parent 4d30363187
commit f2a058b6b2
28 changed files with 1261 additions and 1 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

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

View 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

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

View 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

View file

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

View 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

View 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

View 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

View 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

View file

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

View file

@ -0,0 +1,8 @@
FactoryBot.define do
factory :crew_gw_participation do
crew
gw_event
preliminary_ranking { nil }
final_ranking { nil }
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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