From a3a013852692bc3ab21640f330fa87218960aa90 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 23:47:25 -0800 Subject: [PATCH] add request specs for crew controllers (phases 1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add crews_spec.rb (18 examples) - add crew_memberships_spec.rb (13 examples) - add crew_invitations_spec.rb (15 examples) - fix crew_memberships authorize_crew_captain! as before_action - update crew_invitations factory to auto-set invited_by officer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../api/v1/crew_memberships_controller.rb | 5 +- spec/factories/crew_invitations.rb | 14 +- spec/requests/api/v1/crew_invitations_spec.rb | 203 ++++++++++++++++ spec/requests/api/v1/crew_memberships_spec.rb | 167 +++++++++++++ spec/requests/api/v1/crews_spec.rb | 224 ++++++++++++++++++ 5 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 spec/requests/api/v1/crew_invitations_spec.rb create mode 100644 spec/requests/api/v1/crew_memberships_spec.rb create mode 100644 spec/requests/api/v1/crews_spec.rb diff --git a/app/controllers/api/v1/crew_memberships_controller.rb b/app/controllers/api/v1/crew_memberships_controller.rb index c5b7285..6e67b6f 100644 --- a/app/controllers/api/v1/crew_memberships_controller.rb +++ b/app/controllers/api/v1/crew_memberships_controller.rb @@ -9,13 +9,10 @@ module Api before_action :set_crew before_action :set_membership, only: %i[update destroy promote demote] before_action :authorize_crew_officer!, only: %i[destroy] - before_action :authorize_crew_captain!, only: %i[promote demote] + before_action :authorize_crew_captain!, only: %i[update promote demote] # PUT /crews/:crew_id/memberships/:id def update - # Only captain can update roles - authorize_crew_captain! - if @membership.update(membership_params) render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership) else diff --git a/spec/factories/crew_invitations.rb b/spec/factories/crew_invitations.rb index 367f457..4897e98 100644 --- a/spec/factories/crew_invitations.rb +++ b/spec/factories/crew_invitations.rb @@ -2,10 +2,22 @@ FactoryBot.define do factory :crew_invitation do crew user - association :invited_by, factory: :user status { :pending } expires_at { 7.days.from_now } + # invited_by must be an officer of the crew + after(:build) do |invitation, _evaluator| + unless invitation.invited_by + # Create an officer for the crew if one doesn't exist + officer = invitation.crew.crew_memberships.find_by(role: [:captain, :vice_captain], retired: false)&.user + unless officer + officer = create(:user) + create(:crew_membership, crew: invitation.crew, user: officer, role: :captain) + end + invitation.invited_by = officer + end + end + trait :accepted do status { :accepted } end diff --git a/spec/requests/api/v1/crew_invitations_spec.rb b/spec/requests/api/v1/crew_invitations_spec.rb new file mode 100644 index 0000000..2ef50f7 --- /dev/null +++ b/spec/requests/api/v1/crew_invitations_spec.rb @@ -0,0 +1,203 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::CrewInvitations', 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!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + describe 'GET /api/v1/crews/:crew_id/invitations' do + context 'as officer' do + let!(:pending_invitation) { create(:crew_invitation, crew: crew, invited_by: user, status: :pending) } + let!(:accepted_invitation) { create(:crew_invitation, crew: crew, invited_by: user, status: :accepted) } + + it 'returns pending invitations' do + get "/api/v1/crews/#{crew.id}/invitations", headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['invitations'].length).to eq(1) + expect(json['invitations'][0]['id']).to eq(pending_invitation.id) + end + end + + context 'as regular member' do + let(:actual_captain) { create(:user) } + let!(:captain_membership) { create(:crew_membership, crew: crew, user: actual_captain, role: :captain) } + let!(:member_membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + get "/api/v1/crews/#{crew.id}/invitations", headers: auth_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/crews/:crew_id/invitations' do + let(:invitee) { create(:user) } + + context 'as officer' do + it 'creates an invitation by user_id' do + expect { + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: invitee.id }, + headers: auth_headers + }.to change(CrewInvitation, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['invitation']['user']['id']).to eq(invitee.id) + end + + it 'creates an invitation by username' do + post "/api/v1/crews/#{crew.id}/invitations", + params: { username: invitee.username }, + headers: auth_headers + + expect(response).to have_http_status(:created) + end + + it 'returns error for non-existent user' do + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: SecureRandom.uuid }, + headers: auth_headers + + expect(response).to have_http_status(:not_found) + end + + it 'returns error for self-invitation' do + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: user.id }, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('cannot_invite_self') + end + + it 'returns error if user already in a crew' do + other_crew = create(:crew) + create(:crew_membership, crew: other_crew, user: invitee) + + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: invitee.id }, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('already_in_crew') + end + + it 'returns error if user already invited' do + create(:crew_invitation, crew: crew, user: invitee, invited_by: user, status: :pending) + + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: invitee.id }, + headers: auth_headers + + expect(response).to have_http_status(:conflict) + json = JSON.parse(response.body) + expect(json['code']).to eq('user_already_invited') + end + end + + context 'as vice captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'can create invitations' do + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: invitee.id }, + headers: auth_headers + + expect(response).to have_http_status(:created) + end + end + + context 'as regular member' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'returns unauthorized' do + post "/api/v1/crews/#{crew.id}/invitations", + params: { user_id: invitee.id }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/invitations/pending' do + let(:invitee) { create(:user) } + let(:invitee_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: invitee.id, expires_in: 30.days, scopes: 'public') + end + let(:invitee_headers) { { 'Authorization' => "Bearer #{invitee_token.token}" } } + + let!(:pending1) { create(:crew_invitation, user: invitee, status: :pending) } + let!(:pending2) { create(:crew_invitation, user: invitee, status: :pending) } + let!(:expired) { create(:crew_invitation, user: invitee, status: :expired) } + + it 'returns pending invitations for current user' do + get '/api/v1/invitations/pending', headers: invitee_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['invitations'].length).to eq(2) + end + end + + describe 'POST /api/v1/invitations/:id/accept' do + let(:invitee) { create(:user) } + let(:invitee_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: invitee.id, expires_in: 30.days, scopes: 'public') + end + let(:invitee_headers) { { 'Authorization' => "Bearer #{invitee_token.token}" } } + let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, status: :pending) } + + it 'accepts the invitation and joins the crew' do + post "/api/v1/invitations/#{invitation.id}/accept", headers: invitee_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew']['id']).to eq(crew.id) + expect(invitation.reload.status).to eq('accepted') + expect(invitee.reload.crew).to eq(crew) + end + + it 'returns error for wrong user' do + post "/api/v1/invitations/#{invitation.id}/accept", headers: auth_headers + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json['code']).to eq('invitation_not_found') + end + end + + describe 'POST /api/v1/invitations/:id/reject' do + let(:invitee) { create(:user) } + let(:invitee_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: invitee.id, expires_in: 30.days, scopes: 'public') + end + let(:invitee_headers) { { 'Authorization' => "Bearer #{invitee_token.token}" } } + let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, status: :pending) } + + it 'rejects the invitation' do + post "/api/v1/invitations/#{invitation.id}/reject", headers: invitee_headers + + expect(response).to have_http_status(:no_content) + expect(invitation.reload.status).to eq('rejected') + end + + it 'returns error for wrong user' do + post "/api/v1/invitations/#{invitation.id}/reject", headers: auth_headers + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json['code']).to eq('invitation_not_found') + end + end +end diff --git a/spec/requests/api/v1/crew_memberships_spec.rb b/spec/requests/api/v1/crew_memberships_spec.rb new file mode 100644 index 0000000..534696d --- /dev/null +++ b/spec/requests/api/v1/crew_memberships_spec.rb @@ -0,0 +1,167 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::CrewMemberships', 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(:target_user) { create(:user) } + let!(:target_membership) { create(:crew_membership, crew: crew, user: target_user, role: :member) } + + describe 'PUT /api/v1/crews/:crew_id/memberships/:id' do + context 'as captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'updates member role' do + put "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}", + params: { membership: { role: 'vice_captain' } }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['membership']['role']).to eq('vice_captain') + end + end + + context 'as vice captain' do + let!(:vc_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'returns unauthorized' do + put "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}", + params: { membership: { role: 'vice_captain' } }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/crews/:crew_id/memberships/:id' do + context 'as captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'removes a member' do + delete "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}", + headers: auth_headers + + expect(response).to have_http_status(:no_content) + expect(target_membership.reload.retired).to be true + end + + it 'cannot remove self (captain)' do + delete "/api/v1/crews/#{crew.id}/memberships/#{captain_membership.id}", + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('cannot_remove_captain') + end + end + + context 'as vice captain' do + let!(:vc_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'can remove regular members' do + delete "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}", + headers: auth_headers + + 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/crews/#{crew.id}/memberships/#{target_membership.id}", + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/crews/:crew_id/memberships/:id/promote' do + context 'as captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'promotes member to vice captain' do + post "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}/promote", + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['membership']['role']).to eq('vice_captain') + end + + it 'returns error when VC limit reached' do + 3.times { create(:crew_membership, crew: crew, role: :vice_captain) } + + post "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}/promote", + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('vice_captain_limit') + end + + it 'cannot promote captain' do + post "/api/v1/crews/#{crew.id}/memberships/#{captain_membership.id}/promote", + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as vice captain' do + let!(:vc_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'returns unauthorized' do + post "/api/v1/crews/#{crew.id}/memberships/#{target_membership.id}/promote", + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/crews/:crew_id/memberships/:id/demote' do + let!(:vc_target) { create(:crew_membership, crew: crew, role: :vice_captain) } + + context 'as captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'demotes vice captain to member' do + post "/api/v1/crews/#{crew.id}/memberships/#{vc_target.id}/demote", + headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['membership']['role']).to eq('member') + end + + it 'cannot demote captain' do + post "/api/v1/crews/#{crew.id}/memberships/#{captain_membership.id}/demote", + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('cannot_demote_captain') + end + end + + context 'as vice captain' do + let!(:vc_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'returns unauthorized' do + post "/api/v1/crews/#{crew.id}/memberships/#{vc_target.id}/demote", + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/v1/crews_spec.rb b/spec/requests/api/v1/crews_spec.rb new file mode 100644 index 0000000..6d2f8b2 --- /dev/null +++ b/spec/requests/api/v1/crews_spec.rb @@ -0,0 +1,224 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Crews', 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}" } } + + describe 'POST /api/v1/crews' do + let(:valid_params) do + { + crew: { + name: 'Test Crew', + gamertag: 'TEST', + description: 'A test crew' + } + } + end + + context 'when user has no crew' do + it 'creates a crew and makes user captain' do + expect { + post '/api/v1/crews', params: valid_params, headers: auth_headers + }.to change(Crew, :count).by(1) + .and change(CrewMembership, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['crew']['name']).to eq('Test Crew') + expect(json['crew']['gamertag']).to eq('TEST') + + expect(user.reload.crew).to be_present + expect(user.active_crew_membership.role).to eq('captain') + end + + it 'returns validation error for missing name' do + post '/api/v1/crews', params: { crew: { name: '' } }, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when user already has a crew' do + before do + crew = create(:crew) + create(:crew_membership, crew: crew, user: user, role: :captain) + end + + it 'returns error' do + post '/api/v1/crews', params: valid_params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('already_in_crew') + end + end + + context 'without authentication' do + it 'returns unauthorized' do + post '/api/v1/crews', params: valid_params + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/crew' do + context 'when user has a crew' do + let(:crew) { create(:crew, name: 'My Crew') } + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'returns the crew' do + get '/api/v1/crew', headers: auth_headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew']['name']).to eq('My Crew') + end + end + + context 'when user has no crew' do + it 'returns not found' do + get '/api/v1/crew', headers: auth_headers + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'PUT /api/v1/crew' do + let(:crew) { create(:crew, name: 'Original Name') } + + context 'as captain' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'updates the crew' do + put '/api/v1/crew', params: { crew: { name: 'New Name' } }, headers: auth_headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['crew']['name']).to eq('New Name') + end + end + + context 'as vice captain' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'updates the crew' do + put '/api/v1/crew', params: { crew: { description: 'Updated' } }, headers: auth_headers + expect(response).to have_http_status(:ok) + 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', params: { crew: { name: 'New Name' } }, headers: auth_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/crew/members' do + let(:crew) { create(:crew) } + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + let!(:member1) { create(:crew_membership, crew: crew, role: :member) } + let!(:member2) { create(:crew_membership, crew: crew, role: :vice_captain) } + + it 'returns all active crew members' do + get '/api/v1/crew/members', headers: auth_headers + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['members'].length).to eq(3) + end + + it 'excludes retired members' do + member1.retire! + get '/api/v1/crew/members', headers: auth_headers + json = JSON.parse(response.body) + expect(json['members'].length).to eq(2) + end + end + + describe 'POST /api/v1/crew/leave' do + let(:crew) { create(:crew) } + + context 'as regular member' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + it 'leaves the crew' do + post '/api/v1/crew/leave', headers: auth_headers + expect(response).to have_http_status(:no_content) + expect(membership.reload.retired).to be true + expect(user.reload.crew).to be_nil + end + end + + context 'as vice captain' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'leaves the crew' do + post '/api/v1/crew/leave', headers: auth_headers + expect(response).to have_http_status(:no_content) + end + end + + context 'as captain' do + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + + it 'returns error' do + post '/api/v1/crew/leave', headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('captain_cannot_leave') + end + end + + context 'when not in a crew' do + it 'returns error' do + post '/api/v1/crew/leave', 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 'POST /api/v1/crews/:id/transfer_captain' do + let(:crew) { create(:crew) } + let(:new_captain) { create(:user) } + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) } + let!(:new_captain_membership) { create(:crew_membership, crew: crew, user: new_captain, role: :member) } + + context 'as captain' do + it 'transfers captainship to another member' do + post "/api/v1/crews/#{crew.id}/transfer_captain", + params: { user_id: new_captain.id }, + headers: auth_headers + + expect(response).to have_http_status(:ok) + expect(captain_membership.reload.role).to eq('vice_captain') + expect(new_captain_membership.reload.role).to eq('captain') + end + + it 'returns error for non-existent member' do + post "/api/v1/crews/#{crew.id}/transfer_captain", + params: { user_id: SecureRandom.uuid }, + headers: auth_headers + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json['code']).to eq('member_not_found') + end + end + + context 'as vice captain' do + let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :vice_captain) } + + it 'returns unauthorized' do + post "/api/v1/crews/#{crew.id}/transfer_captain", + params: { user_id: new_captain.id }, + headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end