- create crew_invitations table with status enum - add CrewInvitation model with accept/reject flow - add CrewInvitationsController for send/accept/reject - add invitation error classes - add invitation routes nested under crews - add pending invitations endpoint for current user - 38 passing specs for model and controller
181 lines
5.8 KiB
Ruby
181 lines
5.8 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe CrewInvitation, type: :model do
|
|
let(:crew) { create(:crew) }
|
|
let(:captain) { create(:user) }
|
|
let(:invitee) { create(:user) }
|
|
|
|
before do
|
|
create(:crew_membership, :captain, crew: crew, user: captain)
|
|
end
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:crew) }
|
|
it { is_expected.to belong_to(:user) }
|
|
it { is_expected.to belong_to(:invited_by).class_name('User') }
|
|
end
|
|
|
|
describe 'validations' do
|
|
context 'when user is already in a crew' do
|
|
let(:other_crew) { create(:crew) }
|
|
|
|
before do
|
|
create(:crew_membership, crew: other_crew, user: invitee)
|
|
end
|
|
|
|
it 'is invalid' do
|
|
invitation = build(:crew_invitation, crew: crew, user: invitee, invited_by: captain)
|
|
expect(invitation).not_to be_valid
|
|
expect(invitation.errors[:user]).to include('is already in a crew')
|
|
end
|
|
end
|
|
|
|
context 'when inviter is not an officer' do
|
|
let(:regular_member) { create(:user) }
|
|
|
|
before do
|
|
create(:crew_membership, crew: crew, user: regular_member)
|
|
end
|
|
|
|
it 'is invalid' do
|
|
invitation = build(:crew_invitation, crew: crew, user: invitee, invited_by: regular_member)
|
|
expect(invitation).not_to be_valid
|
|
expect(invitation.errors[:invited_by]).to include('must be an officer of the crew')
|
|
end
|
|
end
|
|
|
|
context 'when inviter is captain' do
|
|
it 'is valid' do
|
|
invitation = build(:crew_invitation, crew: crew, user: invitee, invited_by: captain)
|
|
expect(invitation).to be_valid
|
|
end
|
|
end
|
|
|
|
context 'when inviter is vice captain' do
|
|
let(:vice_captain) { create(:user) }
|
|
|
|
before do
|
|
create(:crew_membership, :vice_captain, crew: crew, user: vice_captain)
|
|
end
|
|
|
|
it 'is valid' do
|
|
invitation = build(:crew_invitation, crew: crew, user: invitee, invited_by: vice_captain)
|
|
expect(invitation).to be_valid
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#accept!' do
|
|
let(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'creates a crew membership' do
|
|
expect { invitation.accept! }.to change(CrewMembership, :count).by(1)
|
|
end
|
|
|
|
it 'sets the invitation status to accepted' do
|
|
invitation.accept!
|
|
expect(invitation.reload.status).to eq('accepted')
|
|
end
|
|
|
|
it 'makes the user a member of the crew' do
|
|
invitation.accept!
|
|
expect(invitee.reload.crew).to eq(crew)
|
|
expect(invitee.crew_role).to eq('member')
|
|
end
|
|
|
|
context 'when invitation is expired by time' do
|
|
let(:invitation) { create(:crew_invitation, :expired_by_time, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'raises InvitationExpiredError' do
|
|
expect { invitation.accept! }.to raise_error(CrewErrors::InvitationExpiredError)
|
|
end
|
|
end
|
|
|
|
context 'when invitation status is expired' do
|
|
let(:invitation) { create(:crew_invitation, :expired, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'raises InvitationExpiredError' do
|
|
expect { invitation.accept! }.to raise_error(CrewErrors::InvitationExpiredError)
|
|
end
|
|
end
|
|
|
|
context 'when user joins another crew after invitation was created' do
|
|
let(:other_crew) { create(:crew) }
|
|
|
|
it 'raises AlreadyInCrewError' do
|
|
# Create invitation first while user is not in any crew
|
|
inv = invitation
|
|
|
|
# Then user joins another crew
|
|
create(:crew_membership, :captain, crew: other_crew)
|
|
create(:crew_membership, crew: other_crew, user: invitee)
|
|
|
|
# Now accepting should fail
|
|
expect { inv.accept! }.to raise_error(CrewErrors::AlreadyInCrewError)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#reject!' do
|
|
let(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'sets the invitation status to rejected' do
|
|
invitation.reject!
|
|
expect(invitation.reload.status).to eq('rejected')
|
|
end
|
|
|
|
context 'when invitation is already expired' do
|
|
let(:invitation) { create(:crew_invitation, :expired, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'raises InvitationExpiredError' do
|
|
expect { invitation.reject! }.to raise_error(CrewErrors::InvitationExpiredError)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#active?' do
|
|
context 'when pending and not expired' do
|
|
let(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'returns true' do
|
|
expect(invitation.active?).to be true
|
|
end
|
|
end
|
|
|
|
context 'when pending but expired by time' do
|
|
let(:invitation) { create(:crew_invitation, :expired_by_time, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'returns false' do
|
|
expect(invitation.active?).to be false
|
|
end
|
|
end
|
|
|
|
context 'when already accepted' do
|
|
let(:invitation) { create(:crew_invitation, :accepted, crew: crew, user: invitee, invited_by: captain) }
|
|
|
|
it 'returns false' do
|
|
expect(invitation.active?).to be false
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'expiration' do
|
|
it 'sets default expiration to 7 days' do
|
|
invitation = create(:crew_invitation, crew: crew, user: invitee, invited_by: captain, expires_at: nil)
|
|
expect(invitation.expires_at).to be_within(1.minute).of(7.days.from_now)
|
|
end
|
|
end
|
|
|
|
describe 'scopes' do
|
|
describe '.active' do
|
|
let!(:active_invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
|
|
let(:other_user) { create(:user) }
|
|
let!(:expired_invitation) { create(:crew_invitation, :expired_by_time, crew: crew, user: other_user, invited_by: captain) }
|
|
|
|
it 'returns only active invitations' do
|
|
expect(CrewInvitation.active).to include(active_invitation)
|
|
expect(CrewInvitation.active).not_to include(expired_invitation)
|
|
end
|
|
end
|
|
end
|
|
end
|