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:
parent
c3e992a0dd
commit
b75a905e2e
12 changed files with 746 additions and 1 deletions
36
app/blueprints/api/v1/crew_invitation_blueprint.rb
Normal file
36
app/blueprints/api/v1/crew_invitation_blueprint.rb
Normal 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
|
||||
78
app/controllers/api/v1/crew_invitations_controller.rb
Normal file
78
app/controllers/api/v1/crew_invitations_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
app/models/crew_invitation.rb
Normal file
64
app/models/crew_invitation.rb
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
db/migrate/20251204070124_create_crew_invitations.rb
Normal file
18
db/migrate/20251204070124_create_crew_invitations.rb
Normal 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
|
||||
32
db/schema.rb
32
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_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"
|
||||
|
|
|
|||
25
spec/factories/crew_invitations.rb
Normal file
25
spec/factories/crew_invitations.rb
Normal 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
|
||||
181
spec/models/crew_invitation_spec.rb
Normal file
181
spec/models/crew_invitation_spec.rb
Normal 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
|
||||
239
spec/requests/crew_invitations_controller_spec.rb
Normal file
239
spec/requests/crew_invitations_controller_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue