From 7f57c2c3eed072299b92fe8c2b644b043a192a64 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 23:40:38 -0800 Subject: [PATCH] fix gw controller params and add request specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use gw_participation_id param (matches route param name) - use gw_crew_score root key for consistency - add crew_gw_participations request specs - add gw_crew_scores request specs - add gw_individual_scores request specs - fix batch authorization to return early 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../api/v1/gw_crew_scores_controller.rb | 12 +- .../api/v1/gw_individual_scores_controller.rb | 12 +- .../api/v1/crew_gw_participations_spec.rb | 122 ++++++++ spec/requests/api/v1/gw_crew_scores_spec.rb | 137 +++++++++ .../api/v1/gw_individual_scores_spec.rb | 265 ++++++++++++++++++ 5 files changed, 536 insertions(+), 12 deletions(-) create mode 100644 spec/requests/api/v1/crew_gw_participations_spec.rb create mode 100644 spec/requests/api/v1/gw_crew_scores_spec.rb create mode 100644 spec/requests/api/v1/gw_individual_scores_spec.rb diff --git a/app/controllers/api/v1/gw_crew_scores_controller.rb b/app/controllers/api/v1/gw_crew_scores_controller.rb index 563171e..b74749e 100644 --- a/app/controllers/api/v1/gw_crew_scores_controller.rb +++ b/app/controllers/api/v1/gw_crew_scores_controller.rb @@ -11,27 +11,27 @@ module Api before_action :set_participation before_action :set_score, only: %i[update destroy] - # POST /crew/gw_participations/:participation_id/crew_scores + # POST /crew/gw_participations/:gw_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 + render json: GwCrewScoreBlueprint.render(score, root: :gw_crew_score), status: :created else render_validation_error_response(score) end end - # PUT /crew/gw_participations/:participation_id/crew_scores/:id + # PUT /crew/gw_participations/:gw_participation_id/crew_scores/:id def update if @score.update(score_params) - render json: GwCrewScoreBlueprint.render(@score, root: :crew_score) + render json: GwCrewScoreBlueprint.render(@score, root: :gw_crew_score) else render_validation_error_response(@score) end end - # DELETE /crew/gw_participations/:participation_id/crew_scores/:id + # DELETE /crew/gw_participations/:gw_participation_id/crew_scores/:id def destroy @score.destroy! head :no_content @@ -45,7 +45,7 @@ module Api end def set_participation - @participation = @crew.crew_gw_participations.find(params[:participation_id]) + @participation = @crew.crew_gw_participations.find(params[:gw_participation_id]) end def set_score diff --git a/app/controllers/api/v1/gw_individual_scores_controller.rb b/app/controllers/api/v1/gw_individual_scores_controller.rb index 5493297..d46e88f 100644 --- a/app/controllers/api/v1/gw_individual_scores_controller.rb +++ b/app/controllers/api/v1/gw_individual_scores_controller.rb @@ -11,7 +11,7 @@ module Api before_action :set_participation before_action :set_score, only: %i[update destroy] - # POST /crew/gw_participations/:participation_id/individual_scores + # POST /crew/gw_participations/:gw_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] @@ -29,7 +29,7 @@ module Api end end - # PUT /crew/gw_participations/:participation_id/individual_scores/:id + # PUT /crew/gw_participations/:gw_participation_id/individual_scores/:id def update unless can_record_score_for?(@score.crew_membership_id) raise Api::V1::UnauthorizedError @@ -42,7 +42,7 @@ module Api end end - # DELETE /crew/gw_participations/:participation_id/individual_scores/:id + # DELETE /crew/gw_participations/:gw_participation_id/individual_scores/:id def destroy unless can_record_score_for?(@score.crew_membership_id) raise Api::V1::UnauthorizedError @@ -52,9 +52,9 @@ module Api head :no_content end - # POST /crew/gw_participations/:participation_id/individual_scores/batch + # POST /crew/gw_participations/:gw_participation_id/individual_scores/batch def batch - authorize_crew_officer! + return render_unauthorized_response unless current_user.crew_officer? scores_params = params.require(:scores) results = [] @@ -94,7 +94,7 @@ module Api end def set_participation - @participation = @crew.crew_gw_participations.find(params[:participation_id]) + @participation = @crew.crew_gw_participations.find(params[:gw_participation_id]) end def set_score diff --git a/spec/requests/api/v1/crew_gw_participations_spec.rb b/spec/requests/api/v1/crew_gw_participations_spec.rb new file mode 100644 index 0000000..a6ac128 --- /dev/null +++ b/spec/requests/api/v1/crew_gw_participations_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::CrewGwParticipations', type: :request do + let(:user) { create(:user) } + let(:access_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public') + end + let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } } + + let(:crew) { create(:crew) } + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + let(:gw_event) { create(:gw_event) } + + describe 'POST /api/v1/gw_events/:id/participations' do + context 'as crew officer' do + it 'joins the crew to an event' do + expect { + post "/api/v1/gw_events/#{gw_event.id}/participations", headers: auth_headers + }.to change(CrewGwParticipation, :count).by(1) + + expect(response).to have_http_status(:created) + end + + it 'returns error if already participating' do + create(:crew_gw_participation, crew: crew, gw_event: gw_event) + + post "/api/v1/gw_events/#{gw_event.id}/participations", headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + post "/api/v1/gw_events/#{gw_event.id}/participations", headers: auth_headers + expect(response).to have_http_status(:unauthorized) + end + end + + context 'without a crew' do + let!(:membership) { nil } + + it 'returns unprocessable_entity' do + post "/api/v1/gw_events/#{gw_event.id}/participations", headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('not_in_crew') + end + end + end + + describe 'GET /api/v1/crew/gw_participations' do + let!(:participation1) { create(:crew_gw_participation, crew: crew) } + let!(:participation2) { create(:crew_gw_participation, crew: crew) } + let!(:other_participation) { create(:crew_gw_participation) } + + it 'returns crew participations' do + get '/api/v1/crew/gw_participations', headers: auth_headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew_gw_participations'].length).to eq(2) + end + + context 'without a crew' do + let!(:membership) { nil } + let!(:participation1) { nil } + let!(:participation2) { nil } + + it 'returns unprocessable_entity' do + get '/api/v1/crew/gw_participations', headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'GET /api/v1/crew/gw_participations/:id' do + let!(:participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) } + + it 'returns the participation' do + get "/api/v1/crew/gw_participations/#{participation.id}", headers: auth_headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew_gw_participation']['id']).to eq(participation.id) + end + + it 'returns 404 for other crew participation' do + other_participation = create(:crew_gw_participation) + get "/api/v1/crew/gw_participations/#{other_participation.id}", headers: auth_headers + expect(response).to have_http_status(:not_found) + end + end + + describe 'PUT /api/v1/crew/gw_participations/:id' do + let!(:participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) } + + context 'as officer' do + it 'updates rankings' do + put "/api/v1/crew/gw_participations/#{participation.id}", + params: { crew_gw_participation: { preliminary_ranking: 1500, final_ranking: 1200 } }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew_gw_participation']['preliminary_ranking']).to eq(1500) + expect(json['crew_gw_participation']['final_ranking']).to eq(1200) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + put "/api/v1/crew/gw_participations/#{participation.id}", + params: { crew_gw_participation: { preliminary_ranking: 1500 } }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/v1/gw_crew_scores_spec.rb b/spec/requests/api/v1/gw_crew_scores_spec.rb new file mode 100644 index 0000000..516038e --- /dev/null +++ b/spec/requests/api/v1/gw_crew_scores_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::GwCrewScores', type: :request do + let(:user) { create(:user) } + let(:access_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public') + end + let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } } + + let(:crew) { create(:crew) } + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + let(:gw_event) { create(:gw_event) } + let!(:participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) } + + describe 'POST /api/v1/crew/gw_participations/:gw_participation_id/crew_scores' do + let(:valid_params) do + { + crew_score: { + round: 'preliminaries', + crew_score: 5_000_000 + } + } + end + + context 'as crew officer' do + it 'creates a crew score' do + expect { + post "/api/v1/crew/gw_participations/#{participation.id}/crew_scores", + params: valid_params, + headers: auth_headers + }.to change(GwCrewScore, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['gw_crew_score']['round']).to eq('preliminaries') + expect(json['gw_crew_score']['crew_score']).to eq(5_000_000) + end + + it 'creates a score with opponent info' do + params = { + crew_score: { + round: 'finals_day_1', + crew_score: 10_000_000, + opponent_score: 8_000_000, + opponent_name: 'Rival Crew', + opponent_granblue_id: '12345678' + } + } + + post "/api/v1/crew/gw_participations/#{participation.id}/crew_scores", + params: params, + headers: auth_headers + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['gw_crew_score']['opponent_name']).to eq('Rival Crew') + expect(json['gw_crew_score']['victory']).to be true + end + + it 'returns error for duplicate round' do + create(:gw_crew_score, crew_gw_participation: participation, round: :preliminaries) + + post "/api/v1/crew/gw_participations/#{participation.id}/crew_scores", + params: valid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + post "/api/v1/crew/gw_participations/#{participation.id}/crew_scores", + params: valid_params, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/crew/gw_participations/:gw_participation_id/crew_scores/:id' do + let!(:score) { create(:gw_crew_score, crew_gw_participation: participation, round: :preliminaries, crew_score: 1_000_000) } + + context 'as crew officer' do + it 'updates the score' do + put "/api/v1/crew/gw_participations/#{participation.id}/crew_scores/#{score.id}", + params: { crew_score: { crew_score: 2_000_000 } }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['gw_crew_score']['crew_score']).to eq(2_000_000) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + put "/api/v1/crew/gw_participations/#{participation.id}/crew_scores/#{score.id}", + params: { crew_score: { crew_score: 2_000_000 } }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/crew/gw_participations/:gw_participation_id/crew_scores/:id' do + let!(:score) { create(:gw_crew_score, crew_gw_participation: participation) } + + context 'as crew officer' do + it 'deletes the score' do + expect { + delete "/api/v1/crew/gw_participations/#{participation.id}/crew_scores/#{score.id}", + headers: auth_headers + }.to change(GwCrewScore, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + delete "/api/v1/crew/gw_participations/#{participation.id}/crew_scores/#{score.id}", + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/v1/gw_individual_scores_spec.rb b/spec/requests/api/v1/gw_individual_scores_spec.rb new file mode 100644 index 0000000..144dcef --- /dev/null +++ b/spec/requests/api/v1/gw_individual_scores_spec.rb @@ -0,0 +1,265 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::GwIndividualScores', type: :request do + let(:user) { create(:user) } + let(:access_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public') + end + let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } } + + let(:crew) { create(:crew) } + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + let(:gw_event) { create(:gw_event) } + let!(:participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) } + + describe 'POST /api/v1/crew/gw_participations/:gw_participation_id/individual_scores' do + let(:valid_params) do + { + individual_score: { + crew_membership_id: membership.id, + round: 'preliminaries', + score: 1_000_000 + } + } + end + + context 'as crew officer' do + it 'creates an individual score' do + expect { + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores", + params: valid_params, + headers: auth_headers + }.to change(GwIndividualScore, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['individual_score']['score']).to eq(1_000_000) + expect(json['individual_score']['round']).to eq('preliminaries') + end + + it 'can record score for other members' do + other_user = create(:user) + other_membership = create(:crew_membership, crew: crew, user: other_user, role: :member) + + params = { + individual_score: { + crew_membership_id: other_membership.id, + round: 'preliminaries', + score: 500_000 + } + } + + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores", + params: params, + headers: auth_headers + + expect(response).to have_http_status(:created) + end + + it 'returns error for duplicate round per member' do + create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: membership, + round: :preliminaries) + + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores", + params: valid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'can record own score' do + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores", + params: valid_params, + headers: auth_headers + + expect(response).to have_http_status(:created) + end + + it 'cannot record score for other members' do + other_user = create(:user) + other_membership = create(:crew_membership, crew: crew, user: other_user, role: :member) + + params = { + individual_score: { + crew_membership_id: other_membership.id, + round: 'preliminaries', + score: 500_000 + } + } + + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores", + params: params, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/crew/gw_participations/:gw_participation_id/individual_scores/:id' do + let!(:score) do + create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: membership, + round: :preliminaries, + score: 1_000_000) + end + + context 'as crew officer' do + it 'updates the score' do + put "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{score.id}", + params: { individual_score: { score: 2_000_000 } }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['individual_score']['score']).to eq(2_000_000) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'can update own score' do + put "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{score.id}", + params: { individual_score: { score: 2_000_000 } }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + end + + it 'cannot update other member scores' do + other_user = create(:user) + other_membership = create(:crew_membership, crew: crew, user: other_user, role: :member) + other_score = create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: other_membership, + round: :finals_day_1) + + put "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{other_score.id}", + params: { individual_score: { score: 2_000_000 } }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/crew/gw_participations/:gw_participation_id/individual_scores/:id' do + let!(:score) do + create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: membership) + end + + context 'as crew officer' do + it 'deletes the score' do + expect { + delete "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{score.id}", + headers: auth_headers + }.to change(GwIndividualScore, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'can delete own score' do + expect { + delete "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{score.id}", + headers: auth_headers + }.to change(GwIndividualScore, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'cannot delete other member scores' do + other_user = create(:user) + other_membership = create(:crew_membership, crew: crew, user: other_user, role: :member) + other_score = create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: other_membership) + + delete "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/#{other_score.id}", + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/crew/gw_participations/:gw_participation_id/individual_scores/batch' do + let(:other_user) { create(:user) } + let!(:other_membership) { create(:crew_membership, crew: crew, user: other_user, role: :member) } + + context 'as crew officer' do + it 'creates multiple scores' do + params = { + scores: [ + { crew_membership_id: membership.id, round: 'preliminaries', score: 1_000_000 }, + { crew_membership_id: other_membership.id, round: 'preliminaries', score: 500_000 } + ] + } + + expect { + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/batch", + params: params, + headers: auth_headers + }.to change(GwIndividualScore, :count).by(2) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['individual_scores'].length).to eq(2) + end + + it 'updates existing scores in batch' do + existing = create(:gw_individual_score, + crew_gw_participation: participation, + crew_membership: membership, + round: :preliminaries, + score: 100_000) + + params = { + scores: [ + { crew_membership_id: membership.id, round: 'preliminaries', score: 2_000_000 } + ] + } + + expect { + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/batch", + params: params, + headers: auth_headers + }.not_to change(GwIndividualScore, :count) + + expect(response).to have_http_status(:created) + expect(existing.reload.score).to eq(2_000_000) + end + end + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + params = { + scores: [ + { crew_membership_id: membership.id, round: 'preliminaries', score: 1_000_000 } + ] + } + + post "/api/v1/crew/gw_participations/#{participation.id}/individual_scores/batch", + params: params, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end