From f2a058b6b23cdd6f6462ea7ff9eea35fcde917d5 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 23:34:54 -0800 Subject: [PATCH] add GW events and scoring system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/v1/crew_gw_participation_blueprint.rb | 64 ++++++++++ .../api/v1/gw_crew_score_blueprint.rb | 9 ++ app/blueprints/api/v1/gw_event_blueprint.rb | 25 ++++ .../api/v1/gw_individual_score_blueprint.rb | 17 +++ .../v1/crew_gw_participations_controller.rb | 63 ++++++++++ .../api/v1/gw_crew_scores_controller.rb | 60 +++++++++ .../api/v1/gw_events_controller.rb | 57 +++++++++ .../api/v1/gw_individual_scores_controller.rb | 116 ++++++++++++++++++ app/models/crew.rb | 2 + app/models/crew_gw_participation.rb | 39 ++++++ app/models/gw_crew_score.rb | 34 +++++ app/models/gw_event.rb | 42 +++++++ app/models/gw_individual_score.rb | 34 +++++ config/routes.rb | 19 +++ db/migrate/20251204070958_create_gw_events.rb | 18 +++ ...204070959_create_crew_gw_participations.rb | 16 +++ .../20251204071000_create_gw_crew_scores.rb | 19 +++ ...51204071001_create_gw_individual_scores.rb | 20 +++ db/schema.rb | 61 ++++++++- spec/factories/crew_gw_participations.rb | 8 ++ spec/factories/gw_crew_scores.rb | 29 +++++ spec/factories/gw_events.rb | 24 ++++ spec/factories/gw_individual_scores.rb | 10 ++ spec/models/crew_gw_participation_spec.rb | 82 +++++++++++++ spec/models/gw_crew_score_spec.rb | 77 ++++++++++++ spec/models/gw_event_spec.rb | 110 +++++++++++++++++ spec/models/gw_individual_score_spec.rb | 93 ++++++++++++++ spec/requests/api/v1/gw_events_spec.rb | 114 +++++++++++++++++ 28 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/api/v1/crew_gw_participation_blueprint.rb create mode 100644 app/blueprints/api/v1/gw_crew_score_blueprint.rb create mode 100644 app/blueprints/api/v1/gw_event_blueprint.rb create mode 100644 app/blueprints/api/v1/gw_individual_score_blueprint.rb create mode 100644 app/controllers/api/v1/crew_gw_participations_controller.rb create mode 100644 app/controllers/api/v1/gw_crew_scores_controller.rb create mode 100644 app/controllers/api/v1/gw_events_controller.rb create mode 100644 app/controllers/api/v1/gw_individual_scores_controller.rb create mode 100644 app/models/crew_gw_participation.rb create mode 100644 app/models/gw_crew_score.rb create mode 100644 app/models/gw_event.rb create mode 100644 app/models/gw_individual_score.rb create mode 100644 db/migrate/20251204070958_create_gw_events.rb create mode 100644 db/migrate/20251204070959_create_crew_gw_participations.rb create mode 100644 db/migrate/20251204071000_create_gw_crew_scores.rb create mode 100644 db/migrate/20251204071001_create_gw_individual_scores.rb create mode 100644 spec/factories/crew_gw_participations.rb create mode 100644 spec/factories/gw_crew_scores.rb create mode 100644 spec/factories/gw_events.rb create mode 100644 spec/factories/gw_individual_scores.rb create mode 100644 spec/models/crew_gw_participation_spec.rb create mode 100644 spec/models/gw_crew_score_spec.rb create mode 100644 spec/models/gw_event_spec.rb create mode 100644 spec/models/gw_individual_score_spec.rb create mode 100644 spec/requests/api/v1/gw_events_spec.rb diff --git a/app/blueprints/api/v1/crew_gw_participation_blueprint.rb b/app/blueprints/api/v1/crew_gw_participation_blueprint.rb new file mode 100644 index 0000000..e814976 --- /dev/null +++ b/app/blueprints/api/v1/crew_gw_participation_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/gw_crew_score_blueprint.rb b/app/blueprints/api/v1/gw_crew_score_blueprint.rb new file mode 100644 index 0000000..b9c659e --- /dev/null +++ b/app/blueprints/api/v1/gw_crew_score_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/gw_event_blueprint.rb b/app/blueprints/api/v1/gw_event_blueprint.rb new file mode 100644 index 0000000..2861100 --- /dev/null +++ b/app/blueprints/api/v1/gw_event_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/gw_individual_score_blueprint.rb b/app/blueprints/api/v1/gw_individual_score_blueprint.rb new file mode 100644 index 0000000..a80c865 --- /dev/null +++ b/app/blueprints/api/v1/gw_individual_score_blueprint.rb @@ -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 diff --git a/app/controllers/api/v1/crew_gw_participations_controller.rb b/app/controllers/api/v1/crew_gw_participations_controller.rb new file mode 100644 index 0000000..f64a5d4 --- /dev/null +++ b/app/controllers/api/v1/crew_gw_participations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/gw_crew_scores_controller.rb b/app/controllers/api/v1/gw_crew_scores_controller.rb new file mode 100644 index 0000000..563171e --- /dev/null +++ b/app/controllers/api/v1/gw_crew_scores_controller.rb @@ -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 diff --git a/app/controllers/api/v1/gw_events_controller.rb b/app/controllers/api/v1/gw_events_controller.rb new file mode 100644 index 0000000..f2aadcb --- /dev/null +++ b/app/controllers/api/v1/gw_events_controller.rb @@ -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 diff --git a/app/controllers/api/v1/gw_individual_scores_controller.rb b/app/controllers/api/v1/gw_individual_scores_controller.rb new file mode 100644 index 0000000..5493297 --- /dev/null +++ b/app/controllers/api/v1/gw_individual_scores_controller.rb @@ -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 diff --git a/app/models/crew.rb b/app/models/crew.rb index ba1bdff..d1cbc66 100644 --- a/app/models/crew.rb +++ b/app/models/crew.rb @@ -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 diff --git a/app/models/crew_gw_participation.rb b/app/models/crew_gw_participation.rb new file mode 100644 index 0000000..fee4bd0 --- /dev/null +++ b/app/models/crew_gw_participation.rb @@ -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 diff --git a/app/models/gw_crew_score.rb b/app/models/gw_crew_score.rb new file mode 100644 index 0000000..8049f13 --- /dev/null +++ b/app/models/gw_crew_score.rb @@ -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 diff --git a/app/models/gw_event.rb b/app/models/gw_event.rb new file mode 100644 index 0000000..3f541fa --- /dev/null +++ b/app/models/gw_event.rb @@ -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 diff --git a/app/models/gw_individual_score.rb b/app/models/gw_individual_score.rb new file mode 100644 index 0000000..f52c2a8 --- /dev/null +++ b/app/models/gw_individual_score.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index dc8b0c7..7c8613c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20251204070958_create_gw_events.rb b/db/migrate/20251204070958_create_gw_events.rb new file mode 100644 index 0000000..6e840a8 --- /dev/null +++ b/db/migrate/20251204070958_create_gw_events.rb @@ -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 diff --git a/db/migrate/20251204070959_create_crew_gw_participations.rb b/db/migrate/20251204070959_create_crew_gw_participations.rb new file mode 100644 index 0000000..a51770b --- /dev/null +++ b/db/migrate/20251204070959_create_crew_gw_participations.rb @@ -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 diff --git a/db/migrate/20251204071000_create_gw_crew_scores.rb b/db/migrate/20251204071000_create_gw_crew_scores.rb new file mode 100644 index 0000000..9b353df --- /dev/null +++ b/db/migrate/20251204071000_create_gw_crew_scores.rb @@ -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 diff --git a/db/migrate/20251204071001_create_gw_individual_scores.rb b/db/migrate/20251204071001_create_gw_individual_scores.rb new file mode 100644 index 0000000..98a6c9e --- /dev/null +++ b/db/migrate/20251204071001_create_gw_individual_scores.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index a14f8e0..fd3d4ea 100644 --- a/db/schema.rb +++ b/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" diff --git a/spec/factories/crew_gw_participations.rb b/spec/factories/crew_gw_participations.rb new file mode 100644 index 0000000..4bd787e --- /dev/null +++ b/spec/factories/crew_gw_participations.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :crew_gw_participation do + crew + gw_event + preliminary_ranking { nil } + final_ranking { nil } + end +end diff --git a/spec/factories/gw_crew_scores.rb b/spec/factories/gw_crew_scores.rb new file mode 100644 index 0000000..52e165e --- /dev/null +++ b/spec/factories/gw_crew_scores.rb @@ -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 diff --git a/spec/factories/gw_events.rb b/spec/factories/gw_events.rb new file mode 100644 index 0000000..bce003c --- /dev/null +++ b/spec/factories/gw_events.rb @@ -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 diff --git a/spec/factories/gw_individual_scores.rb b/spec/factories/gw_individual_scores.rb new file mode 100644 index 0000000..d5e4719 --- /dev/null +++ b/spec/factories/gw_individual_scores.rb @@ -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 diff --git a/spec/models/crew_gw_participation_spec.rb b/spec/models/crew_gw_participation_spec.rb new file mode 100644 index 0000000..881b281 --- /dev/null +++ b/spec/models/crew_gw_participation_spec.rb @@ -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 diff --git a/spec/models/gw_crew_score_spec.rb b/spec/models/gw_crew_score_spec.rb new file mode 100644 index 0000000..1097070 --- /dev/null +++ b/spec/models/gw_crew_score_spec.rb @@ -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 diff --git a/spec/models/gw_event_spec.rb b/spec/models/gw_event_spec.rb new file mode 100644 index 0000000..cf0ac94 --- /dev/null +++ b/spec/models/gw_event_spec.rb @@ -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 diff --git a/spec/models/gw_individual_score_spec.rb b/spec/models/gw_individual_score_spec.rb new file mode 100644 index 0000000..5631c02 --- /dev/null +++ b/spec/models/gw_individual_score_spec.rb @@ -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 diff --git a/spec/requests/api/v1/gw_events_spec.rb b/spec/requests/api/v1/gw_events_spec.rb new file mode 100644 index 0000000..fad989d --- /dev/null +++ b/spec/requests/api/v1/gw_events_spec.rb @@ -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