From b75a905e2ed18f1efb6c0f426a176aade5d41918 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 23:06:07 -0800 Subject: [PATCH] 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 --- .../api/v1/crew_invitation_blueprint.rb | 36 +++ .../api/v1/crew_invitations_controller.rb | 78 ++++++ app/errors/crew_errors.rb | 56 ++++ app/models/crew.rb | 2 + app/models/crew_invitation.rb | 64 +++++ app/models/user.rb | 3 + config/routes.rb | 13 + .../20251204070124_create_crew_invitations.rb | 18 ++ db/schema.rb | 32 ++- spec/factories/crew_invitations.rb | 25 ++ spec/models/crew_invitation_spec.rb | 181 +++++++++++++ .../crew_invitations_controller_spec.rb | 239 ++++++++++++++++++ 12 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/api/v1/crew_invitation_blueprint.rb create mode 100644 app/controllers/api/v1/crew_invitations_controller.rb create mode 100644 app/models/crew_invitation.rb create mode 100644 db/migrate/20251204070124_create_crew_invitations.rb create mode 100644 spec/factories/crew_invitations.rb create mode 100644 spec/models/crew_invitation_spec.rb create mode 100644 spec/requests/crew_invitations_controller_spec.rb diff --git a/app/blueprints/api/v1/crew_invitation_blueprint.rb b/app/blueprints/api/v1/crew_invitation_blueprint.rb new file mode 100644 index 0000000..8e8b4d2 --- /dev/null +++ b/app/blueprints/api/v1/crew_invitation_blueprint.rb @@ -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 diff --git a/app/controllers/api/v1/crew_invitations_controller.rb b/app/controllers/api/v1/crew_invitations_controller.rb new file mode 100644 index 0000000..3501d42 --- /dev/null +++ b/app/controllers/api/v1/crew_invitations_controller.rb @@ -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 diff --git a/app/errors/crew_errors.rb b/app/errors/crew_errors.rb index 56b18a5..819ddda 100644 --- a/app/errors/crew_errors.rb +++ b/app/errors/crew_errors.rb @@ -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 diff --git a/app/models/crew.rb b/app/models/crew.rb index c075ab8..ba1bdff 100644 --- a/app/models/crew.rb +++ b/app/models/crew.rb @@ -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 diff --git a/app/models/crew_invitation.rb b/app/models/crew_invitation.rb new file mode 100644 index 0000000..041ba05 --- /dev/null +++ b/app/models/crew_invitation.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 502490a..f080d70 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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, diff --git a/config/routes.rb b/config/routes.rb index 48e1015..dc8b0c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20251204070124_create_crew_invitations.rb b/db/migrate/20251204070124_create_crew_invitations.rb new file mode 100644 index 0000000..e250bf6 --- /dev/null +++ b/db/migrate/20251204070124_create_crew_invitations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 680dc16..a14f8e0 100644 --- a/db/schema.rb +++ b/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" diff --git a/spec/factories/crew_invitations.rb b/spec/factories/crew_invitations.rb new file mode 100644 index 0000000..367f457 --- /dev/null +++ b/spec/factories/crew_invitations.rb @@ -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 diff --git a/spec/models/crew_invitation_spec.rb b/spec/models/crew_invitation_spec.rb new file mode 100644 index 0000000..8eb6891 --- /dev/null +++ b/spec/models/crew_invitation_spec.rb @@ -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 diff --git a/spec/requests/crew_invitations_controller_spec.rb b/spec/requests/crew_invitations_controller_spec.rb new file mode 100644 index 0000000..4dbee1c --- /dev/null +++ b/spec/requests/crew_invitations_controller_spec.rb @@ -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