add crew invitations system

- 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
This commit is contained in:
Justin Edmund 2025-12-03 23:06:07 -08:00
parent c3e992a0dd
commit b75a905e2e
12 changed files with 746 additions and 1 deletions

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationBlueprint < ApiBlueprint
fields :status, :expires_at, :created_at
view :default do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :with_user do
field :user do |invitation|
UserBlueprint.render_as_hash(invitation.user, view: :minimal)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :for_invitee do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :full)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
end
end
end
end

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[index create]
before_action :authorize_crew_officer!, only: %i[index create]
before_action :set_invitation, only: %i[accept reject]
# GET /crews/:crew_id/invitations
# List pending invitations for a crew (officers only)
def index
invitations = @crew.crew_invitations.pending.includes(:user, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :with_user, root: :invitations)
end
# POST /crews/:crew_id/invitations
# Send an invitation to a user (officers only)
def create
user = User.find_by(id: params[:user_id]) || User.find_by(username: params[:username])
raise ActiveRecord::RecordNotFound, 'User not found' unless user
raise CrewErrors::CannotInviteSelfError if user.id == current_user.id
raise CrewErrors::AlreadyInCrewError if user.crew.present?
# Check for existing pending invitation
existing = @crew.crew_invitations.pending.find_by(user: user)
raise CrewErrors::UserAlreadyInvitedError if existing
invitation = @crew.crew_invitations.build(
user: user,
invited_by: current_user
)
if invitation.save
render json: CrewInvitationBlueprint.render(invitation, view: :with_user, root: :invitation), status: :created
else
render_validation_error_response(invitation)
end
end
# GET /invitations/pending
# List pending invitations for current user
def pending
invitations = current_user.crew_invitations.active.includes(:crew, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :for_invitee, root: :invitations)
end
# POST /invitations/:id/accept
def accept
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.accept!
render json: CrewBlueprint.render(current_user.crew, view: :full, root: :crew)
end
# POST /invitations/:id/reject
def reject
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.reject!
head :no_content
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_invitation
@invitation = CrewInvitation.find(params[:id])
end
end
end
end

View file

@ -116,4 +116,60 @@ module CrewErrors
'Cannot demote the captain'
end
end
class InvitationExpiredError < CrewError
def http_status
:gone
end
def code
'invitation_expired'
end
def message
'This invitation has expired'
end
end
class InvitationNotFoundError < CrewError
def http_status
:not_found
end
def code
'invitation_not_found'
end
def message
'Invitation not found'
end
end
class CannotInviteSelfError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_invite_self'
end
def message
'You cannot invite yourself'
end
end
class UserAlreadyInvitedError < CrewError
def http_status
:conflict
end
def code
'user_already_invited'
end
def message
'User already has a pending invitation'
end
end
end

View file

@ -5,6 +5,8 @@ class Crew < ApplicationRecord
has_many :users, through: :crew_memberships
has_many :active_memberships, -> { where(retired: false) }, class_name: 'CrewMembership'
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'
validates :name, presence: true, length: { maximum: 100 }
validates :gamertag, length: { maximum: 50 }, allow_nil: true

View file

@ -0,0 +1,64 @@
# frozen_string_literal: true
class CrewInvitation < ApplicationRecord
belongs_to :crew
belongs_to :user
belongs_to :invited_by, class_name: 'User'
enum :status, { pending: 0, accepted: 1, rejected: 2, expired: 3 }
validates :user_id, uniqueness: {
scope: %i[crew_id status],
conditions: -> { where(status: :pending) },
message: 'already has a pending invitation to this crew'
}
validate :user_not_in_crew, on: :create
validate :inviter_is_officer
scope :active, -> { where(status: :pending).where('expires_at IS NULL OR expires_at > ?', Time.current) }
before_create :set_expiration
# Accept the invitation and create membership
def accept!
raise CrewErrors::InvitationExpiredError if expired? || (expires_at.present? && expires_at < Time.current)
raise CrewErrors::AlreadyInCrewError if user.reload.crew.present?
transaction do
update!(status: :accepted)
CrewMembership.create!(crew: crew, user: user, role: :member)
end
end
# Reject the invitation
def reject!
raise CrewErrors::InvitationExpiredError if expired?
update!(status: :rejected)
end
# Check if invitation is still valid
def active?
pending? && (expires_at.nil? || expires_at > Time.current)
end
private
def set_expiration
self.expires_at ||= 7.days.from_now
end
def user_not_in_crew
return unless user&.crew.present?
errors.add(:user, 'is already in a crew')
end
def inviter_is_officer
return unless invited_by.present?
return if invited_by.crew&.id == crew_id && invited_by.crew_officer?
errors.add(:invited_by, 'must be an officer of the crew')
end
end

View file

@ -16,6 +16,9 @@ class User < ApplicationRecord
has_many :crew_memberships, dependent: :destroy
has_one :active_crew_membership, -> { where(retired: false) }, class_name: 'CrewMembership'
has_one :crew, through: :active_crew_membership
has_many :crew_invitations, dependent: :destroy
has_many :pending_crew_invitations, -> { where(status: :pending) }, class_name: 'CrewInvitation'
has_many :sent_crew_invitations, class_name: 'CrewInvitation', foreign_key: :invited_by_id, dependent: :nullify
##### ActiveRecord Validations
validates :username,

View file

@ -178,6 +178,19 @@ Rails.application.routes.draw do
post :demote
end
end
resources :invitations, controller: 'crew_invitations', only: %i[index create]
end
# Invitations for current user
resources :invitations, controller: 'crew_invitations', only: [] do
collection do
get :pending
end
member do
post :accept
post :reject
end
end
# Reading collections - works for any user with privacy check

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class CreateCrewInvitations < ActiveRecord::Migration[8.0]
def change
create_table :crew_invitations, id: :uuid do |t|
t.references :crew, type: :uuid, null: false, foreign_key: true
t.references :user, type: :uuid, null: false, foreign_key: true, comment: 'Invitee'
t.references :invited_by, type: :uuid, null: false, foreign_key: { to_table: :users }
t.integer :status, default: 0, null: false
t.datetime :expires_at
t.timestamps
end
add_index :crew_invitations, %i[crew_id user_id status]
add_index :crew_invitations, %i[user_id status]
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
ActiveRecord::Schema[8.0].define(version: 2025_12_04_070124) 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,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
t.index ["weapon_key4_id"], name: "index_collection_weapons_on_weapon_key4_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"
t.uuid "invited_by_id", null: false
t.integer "status", default: 0, null: false
t.datetime "expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["crew_id", "user_id", "status"], name: "index_crew_invitations_on_crew_id_and_user_id_and_status"
t.index ["crew_id"], name: "index_crew_invitations_on_crew_id"
t.index ["invited_by_id"], name: "index_crew_invitations_on_invited_by_id"
t.index ["user_id", "status"], name: "index_crew_invitations_on_user_id_and_status"
t.index ["user_id"], name: "index_crew_invitations_on_user_id"
end
create_table "crew_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "crew_id", null: false
t.uuid "user_id", null: false
@ -332,7 +347,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "reroll_slot"
t.uuid "collection_artifact_id"
t.index ["artifact_id"], name: "index_grid_artifacts_on_artifact_id"
t.index ["collection_artifact_id"], name: "index_grid_artifacts_on_collection_artifact_id"
t.index ["grid_character_id"], name: "index_grid_artifacts_on_grid_character_id", unique: true
end
@ -352,8 +369,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
t.jsonb "earring", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.uuid "awakening_id"
t.integer "awakening_level", default: 1
t.uuid "collection_character_id"
t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id"
t.index ["character_id"], name: "index_grid_characters_on_character_id"
t.index ["collection_character_id"], name: "index_grid_characters_on_collection_character_id"
t.index ["party_id", "position"], name: "index_grid_characters_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_characters_on_party_id"
end
@ -369,6 +388,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
t.datetime "updated_at", null: false
t.integer "transcendence_step", default: 0, null: false
t.boolean "quick_summon", default: false
t.uuid "collection_summon_id"
t.index ["collection_summon_id"], name: "index_grid_summons_on_collection_summon_id"
t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_summons_on_party_id"
t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
@ -394,7 +415,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) do
t.uuid "awakening_id"
t.integer "transcendence_step", default: 0
t.string "weapon_key4_id"
t.uuid "collection_weapon_id"
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id"
t.index ["collection_weapon_id"], name: "index_grid_weapons_on_collection_weapon_id"
t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_weapons_on_party_id"
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id"
@ -874,19 +897,26 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_063711) 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_invitations", "crews"
add_foreign_key "crew_invitations", "users"
add_foreign_key "crew_invitations", "users", column: "invited_by_id"
add_foreign_key "crew_memberships", "crews"
add_foreign_key "crew_memberships", "users"
add_foreign_key "effects", "effects", column: "effect_family_id"
add_foreign_key "favorites", "parties"
add_foreign_key "favorites", "users"
add_foreign_key "grid_artifacts", "artifacts"
add_foreign_key "grid_artifacts", "collection_artifacts"
add_foreign_key "grid_artifacts", "grid_characters"
add_foreign_key "grid_characters", "awakenings"
add_foreign_key "grid_characters", "characters"
add_foreign_key "grid_characters", "collection_characters"
add_foreign_key "grid_characters", "parties"
add_foreign_key "grid_summons", "collection_summons"
add_foreign_key "grid_summons", "parties"
add_foreign_key "grid_summons", "summons"
add_foreign_key "grid_weapons", "awakenings"
add_foreign_key "grid_weapons", "collection_weapons"
add_foreign_key "grid_weapons", "parties"
add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id"
add_foreign_key "grid_weapons", "weapons"

View file

@ -0,0 +1,25 @@
FactoryBot.define do
factory :crew_invitation do
crew
user
association :invited_by, factory: :user
status { :pending }
expires_at { 7.days.from_now }
trait :accepted do
status { :accepted }
end
trait :rejected do
status { :rejected }
end
trait :expired do
status { :expired }
end
trait :expired_by_time do
expires_at { 1.day.ago }
end
end
end

View file

@ -0,0 +1,181 @@
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

View file

@ -0,0 +1,239 @@
require 'rails_helper'
RSpec.describe 'Crew Invitations API', type: :request do
let(:captain) { create(:user) }
let(:vice_captain) { create(:user) }
let(:member) { create(:user) }
let(:invitee) { create(:user) }
let(:crew) { create(:crew) }
let(:captain_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: captain.id, expires_in: 30.days, scopes: 'public')
end
let(:vc_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: vice_captain.id, expires_in: 30.days, scopes: 'public')
end
let(:member_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: member.id, expires_in: 30.days, scopes: 'public')
end
let(:invitee_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: invitee.id, expires_in: 30.days, scopes: 'public')
end
let(:captain_headers) do
{ 'Authorization' => "Bearer #{captain_token.token}", 'Content-Type' => 'application/json' }
end
let(:vc_headers) do
{ 'Authorization' => "Bearer #{vc_token.token}", 'Content-Type' => 'application/json' }
end
let(:member_headers) do
{ 'Authorization' => "Bearer #{member_token.token}", 'Content-Type' => 'application/json' }
end
let(:invitee_headers) do
{ 'Authorization' => "Bearer #{invitee_token.token}", 'Content-Type' => 'application/json' }
end
before do
create(:crew_membership, :captain, crew: crew, user: captain)
create(:crew_membership, :vice_captain, crew: crew, user: vice_captain)
create(:crew_membership, crew: crew, user: member)
end
describe 'GET /api/v1/crews/:crew_id/invitations' do
let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
context 'as captain' do
it 'returns pending invitations' do
get "/api/v1/crews/#{crew.id}/invitations", headers: captain_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['invitations'].length).to eq(1)
expect(json['invitations'][0]['user']['username']).to eq(invitee.username)
end
end
context 'as vice captain' do
it 'returns pending invitations' do
get "/api/v1/crews/#{crew.id}/invitations", headers: vc_headers
expect(response).to have_http_status(:ok)
end
end
context 'as regular member' do
it 'returns unauthorized' do
get "/api/v1/crews/#{crew.id}/invitations", headers: member_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/crews/:crew_id/invitations' do
context 'as captain' do
it 'creates an invitation' do
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: invitee.id }.to_json,
headers: captain_headers
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['invitation']['user']['username']).to eq(invitee.username)
expect(json['invitation']['status']).to eq('pending')
end
it 'creates an invitation by username' do
post "/api/v1/crews/#{crew.id}/invitations",
params: { username: invitee.username }.to_json,
headers: captain_headers
expect(response).to have_http_status(:created)
end
it 'returns error when user already in a crew' do
other_crew = create(:crew)
create(:crew_membership, :captain, crew: other_crew)
create(:crew_membership, crew: other_crew, user: invitee)
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: invitee.id }.to_json,
headers: captain_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 when inviting self' do
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: captain.id }.to_json,
headers: captain_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 when user already has pending invitation' do
create(:crew_invitation, crew: crew, user: invitee, invited_by: captain)
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: invitee.id }.to_json,
headers: captain_headers
expect(response).to have_http_status(:conflict)
json = JSON.parse(response.body)
expect(json['code']).to eq('user_already_invited')
end
it 'returns not found for non-existent user' do
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: SecureRandom.uuid }.to_json,
headers: captain_headers
expect(response).to have_http_status(:not_found)
end
end
context 'as regular member' do
it 'returns unauthorized' do
post "/api/v1/crews/#{crew.id}/invitations",
params: { user_id: invitee.id }.to_json,
headers: member_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/invitations/pending' do
let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
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(1)
expect(json['invitations'][0]['crew']['name']).to eq(crew.name)
end
it 'does not return expired invitations' do
invitation.update!(expires_at: 1.day.ago)
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(0)
end
it 'does not return accepted invitations' do
invitation.update!(status: :accepted)
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(0)
end
end
describe 'POST /api/v1/invitations/:id/accept' do
let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
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']['name']).to eq(crew.name)
invitee.reload
expect(invitee.crew).to eq(crew)
expect(invitee.crew_role).to eq('member')
end
it 'returns error when accepting someone else invitation' do
other_user = create(:user)
other_token = Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public')
other_headers = { 'Authorization' => "Bearer #{other_token.token}", 'Content-Type' => 'application/json' }
post "/api/v1/invitations/#{invitation.id}/accept", headers: other_headers
expect(response).to have_http_status(:not_found)
end
it 'returns error when invitation is expired' do
invitation.update!(expires_at: 1.day.ago)
post "/api/v1/invitations/#{invitation.id}/accept", headers: invitee_headers
expect(response).to have_http_status(:gone)
json = JSON.parse(response.body)
expect(json['code']).to eq('invitation_expired')
end
end
describe 'POST /api/v1/invitations/:id/reject' do
let!(:invitation) { create(:crew_invitation, crew: crew, user: invitee, invited_by: captain) }
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 when rejecting someone else invitation' do
other_user = create(:user)
other_token = Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public')
other_headers = { 'Authorization' => "Bearer #{other_token.token}", 'Content-Type' => 'application/json' }
post "/api/v1/invitations/#{invitation.id}/reject", headers: other_headers
expect(response).to have_http_status(:not_found)
end
end
end