add phantom players for non-registered crew members

- phantom_players table for tracking scores of non-user members
- claim flow: officer assigns phantom to user, user confirms, scores transfer
- CRUD endpoints plus /assign and /confirm_claim actions
- model/request specs for all functionality (37 examples)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-12-03 23:55:15 -08:00
parent a3a0138526
commit 4c8f4ffcf3
14 changed files with 738 additions and 1 deletions

View file

@ -5,12 +5,30 @@ module Api
class GwIndividualScoreBlueprint < ApiBlueprint
fields :round, :score, :is_cumulative
field :player_name do |score|
score.player_name
end
field :player_type do |score|
if score.crew_membership_id.present?
'member'
elsif score.phantom_player_id.present?
'phantom'
end
end
view :with_member do
field :member do |score|
if score.crew_membership.present?
CrewMembershipBlueprint.render_as_hash(score.crew_membership, view: :with_user)
end
end
field :phantom do |score|
if score.phantom_player.present?
PhantomPlayerBlueprint.render_as_hash(score.phantom_player)
end
end
end
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayerBlueprint < ApiBlueprint
fields :name, :granblue_id, :notes, :claim_confirmed
field :claimed do |phantom|
phantom.claimed_by_id.present?
end
view :with_claimed_by do
field :claimed_by do |phantom|
phantom.claimed_by ? UserBlueprint.render_as_hash(phantom.claimed_by, view: :minimal) : nil
end
end
view :with_scores do
include_view :with_claimed_by
field :total_score do |phantom|
phantom.gw_individual_scores.sum(:score)
end
field :score_count do |phantom|
phantom.gw_individual_scores.count
end
end
end
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayersController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_member!, only: %i[index confirm_claim]
before_action :authorize_crew_officer!, only: %i[create update destroy assign]
before_action :set_phantom, only: %i[show update destroy assign confirm_claim]
# GET /crews/:crew_id/phantom_players
def index
phantoms = @crew.phantom_players.includes(:claimed_by).order(:name)
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_claimed_by, root: :phantom_players)
end
# GET /crews/:crew_id/phantom_players/:id
def show
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_scores, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players
def create
phantom = @crew.phantom_players.build(phantom_params)
if phantom.save
render json: PhantomPlayerBlueprint.render(phantom, root: :phantom_player), status: :created
else
render_validation_error_response(phantom)
end
end
# PUT /crews/:crew_id/phantom_players/:id
def update
if @phantom.update(phantom_params)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
else
render_validation_error_response(@phantom)
end
end
# DELETE /crews/:crew_id/phantom_players/:id
def destroy
@phantom.destroy!
head :no_content
end
# POST /crews/:crew_id/phantom_players/:id/assign
def assign
user = User.find(params[:user_id])
@phantom.assign_to(user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players/:id/confirm_claim
def confirm_claim
@phantom.confirm_claim!(current_user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_phantom
@phantom = @crew.phantom_players.find(params[:id])
end
def phantom_params
params.require(:phantom_player).permit(:name, :granblue_id, :notes)
end
end
end
end

View file

@ -172,4 +172,32 @@ module CrewErrors
'User already has a pending invitation'
end
end
class NotClaimedByUserError < CrewError
def http_status
:forbidden
end
def code
'not_claimed_by_user'
end
def message
'This phantom player is not assigned to you'
end
end
class PhantomNotFoundError < CrewError
def http_status
:not_found
end
def code
'phantom_not_found'
end
def message
'Phantom player not found'
end
end
end

View file

@ -9,6 +9,7 @@ class Crew < ApplicationRecord
has_many :pending_invitations, -> { where(status: :pending) }, class_name: 'CrewInvitation'
has_many :crew_gw_participations, dependent: :destroy
has_many :gw_events, through: :crew_gw_participations
has_many :phantom_players, dependent: :destroy
validates :name, presence: true, length: { maximum: 100 }
validates :gamertag, length: { maximum: 50 }, allow_nil: true

View file

@ -3,6 +3,7 @@
class GwIndividualScore < ApplicationRecord
belongs_to :crew_gw_participation
belongs_to :crew_membership, optional: true
belongs_to :phantom_player, optional: true
belongs_to :recorded_by, class_name: 'User'
# Use same round enum as GwCrewScore
@ -14,13 +15,29 @@ class GwIndividualScore < ApplicationRecord
scope: %i[crew_gw_participation_id round],
message: 'already has a score for this round'
}, if: -> { crew_membership_id.present? }
validates :phantom_player_id, uniqueness: {
scope: %i[crew_gw_participation_id round],
message: 'already has a score for this round'
}, if: -> { phantom_player_id.present? }
validate :membership_belongs_to_crew
validate :phantom_belongs_to_crew
validate :exactly_one_player_reference
delegate :crew, :gw_event, to: :crew_gw_participation
scope :for_round, ->(round) { where(round: round) }
scope :for_membership, ->(membership) { where(crew_membership: membership) }
scope :for_phantom, ->(phantom) { where(phantom_player: phantom) }
# Returns the player name (from membership user or phantom)
def player_name
if crew_membership.present?
crew_membership.user.username
elsif phantom_player.present?
phantom_player.name
end
end
private
@ -31,4 +48,23 @@ class GwIndividualScore < ApplicationRecord
errors.add(:crew_membership, 'must belong to the participating crew')
end
end
def phantom_belongs_to_crew
return unless phantom_player.present?
unless phantom_player.crew_id == crew_gw_participation.crew_id
errors.add(:phantom_player, 'must belong to the participating crew')
end
end
def exactly_one_player_reference
has_membership = crew_membership_id.present?
has_phantom = phantom_player_id.present?
if has_membership && has_phantom
errors.add(:base, 'cannot have both crew_membership and phantom_player')
elsif !has_membership && !has_phantom
errors.add(:base, 'must have either crew_membership or phantom_player')
end
end
end

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true
class PhantomPlayer < ApplicationRecord
belongs_to :crew
belongs_to :claimed_by, class_name: 'User', optional: true
belongs_to :claimed_from_membership, class_name: 'CrewMembership', optional: true
has_many :gw_individual_scores, dependent: :nullify
validates :name, presence: true, length: { maximum: 100 }
validates :granblue_id, length: { maximum: 20 }, allow_blank: true
validates :granblue_id, uniqueness: { scope: :crew_id }, if: -> { granblue_id.present? }
validate :claimed_by_must_be_crew_member, if: :claimed_by_id_changed?
validate :claim_confirmed_requires_claimed_by
scope :unclaimed, -> { where(claimed_by_id: nil) }
scope :claimed, -> { where.not(claimed_by_id: nil) }
scope :pending_confirmation, -> { claimed.where(claim_confirmed: false) }
# Assign this phantom to a user (officer action)
def assign_to(user)
raise CrewErrors::MemberNotFoundError unless user.crew == crew
self.claimed_by = user
self.claim_confirmed = false
save!
end
# Confirm the claim (user action)
def confirm_claim!(user)
raise CrewErrors::NotClaimedByUserError unless claimed_by == user
self.claim_confirmed = true
transfer_scores_to_membership!
save!
end
# Unassign the phantom (officer action or user rejection)
def unassign!
self.claimed_by = nil
self.claim_confirmed = false
save!
end
private
def claimed_by_must_be_crew_member
return unless claimed_by.present?
return if claimed_by.crew == crew
errors.add(:claimed_by, 'must be a member of this crew')
end
def claim_confirmed_requires_claimed_by
return unless claim_confirmed? && claimed_by.blank?
errors.add(:claim_confirmed, 'requires a claimed_by user')
end
def transfer_scores_to_membership!
return unless claimed_by.present?
membership = claimed_by.active_crew_membership
return unless membership
# Transfer all phantom scores to the user's membership
gw_individual_scores.update_all(
crew_membership_id: membership.id,
phantom_player_id: nil
)
end
end

View file

@ -180,6 +180,13 @@ Rails.application.routes.draw do
end
resources :invitations, controller: 'crew_invitations', only: %i[index create]
resources :phantom_players, only: %i[index show create update destroy] do
member do
post :assign
post :confirm_claim
end
end
end
# Invitations for current user

View file

@ -0,0 +1,18 @@
class CreatePhantomPlayers < ActiveRecord::Migration[8.0]
def change
create_table :phantom_players, id: :uuid do |t|
t.references :crew, null: false, foreign_key: true, type: :uuid
t.string :name, null: false
t.string :granblue_id
t.text :notes
t.references :claimed_by, foreign_key: { to_table: :users }, type: :uuid
t.references :claimed_from_membership, foreign_key: { to_table: :crew_memberships }, type: :uuid
t.boolean :claim_confirmed, default: false, null: false
t.timestamps
end
# Unique constraint on granblue_id per crew (only when granblue_id is present)
add_index :phantom_players, [:crew_id, :granblue_id], unique: true, where: 'granblue_id IS NOT NULL'
end
end

View file

@ -0,0 +1,5 @@
class AddPhantomPlayerToGwIndividualScores < ActiveRecord::Migration[8.0]
def change
add_reference :gw_individual_scores, :phantom_player, foreign_key: true, type: :uuid
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_071001) do
ActiveRecord::Schema[8.0].define(version: 2025_12_04_075226) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -482,9 +482,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_071001) do
t.uuid "recorded_by_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "phantom_player_id"
t.index ["crew_gw_participation_id", "crew_membership_id", "round"], name: "idx_gw_individual_scores_unique", unique: true
t.index ["crew_gw_participation_id"], name: "index_gw_individual_scores_on_crew_gw_participation_id"
t.index ["crew_membership_id"], name: "index_gw_individual_scores_on_crew_membership_id"
t.index ["phantom_player_id"], name: "index_gw_individual_scores_on_phantom_player_id"
t.index ["recorded_by_id"], name: "index_gw_individual_scores_on_recorded_by_id"
end
@ -652,6 +654,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_071001) do
t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at"
end
create_table "phantom_players", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "crew_id", null: false
t.string "name", null: false
t.string "granblue_id"
t.text "notes"
t.uuid "claimed_by_id"
t.uuid "claimed_from_membership_id"
t.boolean "claim_confirmed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["claimed_by_id"], name: "index_phantom_players_on_claimed_by_id"
t.index ["claimed_from_membership_id"], name: "index_phantom_players_on_claimed_from_membership_id"
t.index ["crew_id", "granblue_id"], name: "index_phantom_players_on_crew_id_and_granblue_id", unique: true, where: "(granblue_id IS NOT NULL)"
t.index ["crew_id"], name: "index_phantom_players_on_crew_id"
end
create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name_en", null: false
t.string "name_jp", null: false
@ -978,6 +996,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_071001) do
add_foreign_key "gw_crew_scores", "crew_gw_participations"
add_foreign_key "gw_individual_scores", "crew_gw_participations"
add_foreign_key "gw_individual_scores", "crew_memberships"
add_foreign_key "gw_individual_scores", "phantom_players"
add_foreign_key "gw_individual_scores", "users", column: "recorded_by_id"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
@ -993,6 +1012,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_04_071001) do
add_foreign_key "parties", "parties", column: "source_party_id"
add_foreign_key "parties", "raids"
add_foreign_key "parties", "users"
add_foreign_key "phantom_players", "crew_memberships", column: "claimed_from_membership_id"
add_foreign_key "phantom_players", "crews"
add_foreign_key "phantom_players", "users", column: "claimed_by_id"
add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey"
add_foreign_key "skill_effects", "effects", name: "fk_skill_effects_effects"
add_foreign_key "skill_effects", "skills", name: "fk_skill_effects_skills"

View file

@ -0,0 +1,34 @@
FactoryBot.define do
factory :phantom_player do
crew
sequence(:name) { |n| "Phantom Player #{n}" }
granblue_id { nil }
notes { nil }
trait :with_granblue_id do
sequence(:granblue_id) { |n| "#{10000000 + n}" }
end
trait :claimed do
transient do
claimer { nil }
end
after(:build) do |phantom, evaluator|
if evaluator.claimer
phantom.claimed_by = evaluator.claimer
else
# Create a member of the crew
user = create(:user)
create(:crew_membership, crew: phantom.crew, user: user, role: :member)
phantom.claimed_by = user
end
end
end
trait :confirmed do
claimed
claim_confirmed { true }
end
end
end

View file

@ -0,0 +1,162 @@
require 'rails_helper'
RSpec.describe PhantomPlayer, type: :model do
describe 'associations' do
it { should belong_to(:crew) }
it { should belong_to(:claimed_by).class_name('User').optional }
it { should belong_to(:claimed_from_membership).class_name('CrewMembership').optional }
it { should have_many(:gw_individual_scores) }
end
describe 'validations' do
it { should validate_presence_of(:name) }
it { should validate_length_of(:name).is_at_most(100) }
it { should validate_length_of(:granblue_id).is_at_most(20) }
describe 'granblue_id uniqueness within crew' do
let(:crew) { create(:crew) }
let!(:existing) { create(:phantom_player, crew: crew, granblue_id: '12345678') }
it 'rejects duplicate granblue_id in same crew' do
phantom = build(:phantom_player, crew: crew, granblue_id: '12345678')
expect(phantom).not_to be_valid
expect(phantom.errors[:granblue_id]).to be_present
end
it 'allows same granblue_id in different crew' do
other_crew = create(:crew)
phantom = build(:phantom_player, crew: other_crew, granblue_id: '12345678')
expect(phantom).to be_valid
end
it 'allows multiple nil granblue_ids in same crew' do
phantom = build(:phantom_player, crew: crew, granblue_id: nil)
expect(phantom).to be_valid
end
end
describe 'claimed_by must be crew member' do
let(:crew) { create(:crew) }
let(:phantom) { build(:phantom_player, crew: crew) }
it 'accepts claimed_by who is a crew member' do
member = create(:user)
create(:crew_membership, crew: crew, user: member, role: :member)
phantom.claimed_by = member
expect(phantom).to be_valid
end
it 'rejects claimed_by who is not a crew member' do
non_member = create(:user)
phantom.claimed_by = non_member
expect(phantom).not_to be_valid
expect(phantom.errors[:claimed_by]).to include('must be a member of this crew')
end
end
describe 'claim_confirmed requires claimed_by' do
let(:phantom) { build(:phantom_player, claim_confirmed: true, claimed_by: nil) }
it 'is invalid' do
expect(phantom).not_to be_valid
expect(phantom.errors[:claim_confirmed]).to include('requires a claimed_by user')
end
end
end
describe 'scopes' do
let(:crew) { create(:crew) }
let(:member) { create(:user) }
let!(:membership) { create(:crew_membership, crew: crew, user: member, role: :member) }
let!(:unclaimed) { create(:phantom_player, crew: crew) }
let!(:claimed) { create(:phantom_player, crew: crew, claimed_by: member) }
let!(:confirmed) { create(:phantom_player, crew: crew, claimed_by: member, claim_confirmed: true) }
describe '.unclaimed' do
it 'returns only unclaimed phantoms' do
expect(PhantomPlayer.unclaimed).to contain_exactly(unclaimed)
end
end
describe '.claimed' do
it 'returns claimed phantoms (confirmed or not)' do
expect(PhantomPlayer.claimed).to contain_exactly(claimed, confirmed)
end
end
describe '.pending_confirmation' do
it 'returns claimed but unconfirmed phantoms' do
expect(PhantomPlayer.pending_confirmation).to contain_exactly(claimed)
end
end
end
describe '#assign_to' do
let(:crew) { create(:crew) }
let(:phantom) { create(:phantom_player, crew: crew) }
let(:member) { create(:user) }
let!(:membership) { create(:crew_membership, crew: crew, user: member, role: :member) }
it 'assigns the phantom to the user' do
phantom.assign_to(member)
expect(phantom.claimed_by).to eq(member)
expect(phantom.claim_confirmed).to be false
end
it 'raises error for non-crew member' do
non_member = create(:user)
expect { phantom.assign_to(non_member) }.to raise_error(CrewErrors::MemberNotFoundError)
end
end
describe '#confirm_claim!' do
let(:crew) { create(:crew) }
let(:member) { create(:user) }
let!(:membership) { create(:crew_membership, crew: crew, user: member, role: :member) }
let(:phantom) { create(:phantom_player, crew: crew, claimed_by: member) }
it 'confirms the claim' do
phantom.confirm_claim!(member)
expect(phantom.claim_confirmed).to be true
end
it 'raises error for wrong user' do
other_user = create(:user)
create(:crew_membership, crew: crew, user: other_user, role: :member)
expect { phantom.confirm_claim!(other_user) }.to raise_error(CrewErrors::NotClaimedByUserError)
end
context 'with individual scores' do
let(:gw_event) { create(:gw_event) }
let(:participation) { create(:crew_gw_participation, crew: crew, gw_event: gw_event) }
let!(:phantom_score) do
create(:gw_individual_score,
crew_gw_participation: participation,
phantom_player: phantom,
crew_membership: nil,
score: 1_000_000)
end
it 'transfers scores to the membership' do
phantom.confirm_claim!(member)
phantom_score.reload
expect(phantom_score.crew_membership).to eq(membership)
expect(phantom_score.phantom_player).to be_nil
end
end
end
describe '#unassign!' do
let(:crew) { create(:crew) }
let(:member) { create(:user) }
let!(:membership) { create(:crew_membership, crew: crew, user: member, role: :member) }
let(:phantom) { create(:phantom_player, crew: crew, claimed_by: member) }
it 'removes the assignment' do
phantom.unassign!
expect(phantom.claimed_by).to be_nil
expect(phantom.claim_confirmed).to be false
end
end
end

View file

@ -0,0 +1,223 @@
require 'rails_helper'
RSpec.describe 'Api::V1::PhantomPlayers', type: :request do
let(:user) { create(:user) }
let(:access_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public')
end
let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } }
let(:crew) { create(:crew) }
let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :captain) }
describe 'GET /api/v1/crews/:crew_id/phantom_players' do
let!(:phantom1) { create(:phantom_player, crew: crew, name: 'Phantom A') }
let!(:phantom2) { create(:phantom_player, crew: crew, name: 'Phantom B') }
context 'as crew member' do
it 'returns all phantom players' do
get "/api/v1/crews/#{crew.id}/phantom_players", headers: auth_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['phantom_players'].length).to eq(2)
end
end
context 'as non-member' do
let(:other_user) { create(:user) }
let(:other_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public')
end
let(:other_headers) { { 'Authorization' => "Bearer #{other_token.token}" } }
it 'returns unauthorized' do
get "/api/v1/crews/#{crew.id}/phantom_players", headers: other_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/crews/:crew_id/phantom_players/:id' do
let!(:phantom) { create(:phantom_player, crew: crew) }
it 'returns the phantom player with scores' do
get "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}", headers: auth_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['phantom_player']['name']).to eq(phantom.name)
expect(json['phantom_player']).to have_key('total_score')
end
end
describe 'POST /api/v1/crews/:crew_id/phantom_players' do
let(:valid_params) do
{
phantom_player: {
name: 'New Phantom',
granblue_id: '12345678',
notes: 'Former member'
}
}
end
context 'as officer' do
it 'creates a phantom player' do
expect {
post "/api/v1/crews/#{crew.id}/phantom_players",
params: valid_params,
headers: auth_headers
}.to change(PhantomPlayer, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['phantom_player']['name']).to eq('New Phantom')
expect(json['phantom_player']['granblue_id']).to eq('12345678')
end
it 'returns validation error for missing name' do
post "/api/v1/crews/#{crew.id}/phantom_players",
params: { phantom_player: { name: '' } },
headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'as regular member' do
let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :member) }
it 'returns unauthorized' do
post "/api/v1/crews/#{crew.id}/phantom_players",
params: valid_params,
headers: auth_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/crews/:crew_id/phantom_players/:id' do
let!(:phantom) { create(:phantom_player, crew: crew, name: 'Old Name') }
context 'as officer' do
it 'updates the phantom player' do
put "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}",
params: { phantom_player: { name: 'New Name' } },
headers: auth_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['phantom_player']['name']).to eq('New Name')
end
end
context 'as regular member' do
let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :member) }
it 'returns unauthorized' do
put "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}",
params: { phantom_player: { name: 'New Name' } },
headers: auth_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/crews/:crew_id/phantom_players/:id' do
let!(:phantom) { create(:phantom_player, crew: crew) }
context 'as officer' do
it 'deletes the phantom player' do
expect {
delete "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}",
headers: auth_headers
}.to change(PhantomPlayer, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
end
context 'as regular member' do
let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :member) }
it 'returns unauthorized' do
delete "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}",
headers: auth_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/crews/:crew_id/phantom_players/:id/assign' do
let!(:phantom) { create(:phantom_player, crew: crew) }
let(:target_user) { create(:user) }
let!(:target_membership) { create(:crew_membership, crew: crew, user: target_user, role: :member) }
context 'as officer' do
it 'assigns the phantom to a user' do
post "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}/assign",
params: { user_id: target_user.id },
headers: auth_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['phantom_player']['claimed']).to be true
expect(json['phantom_player']['claimed_by']['id']).to eq(target_user.id)
end
it 'returns error for non-crew member' do
non_member = create(:user)
post "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}/assign",
params: { user_id: non_member.id },
headers: auth_headers
expect(response).to have_http_status(:not_found)
end
end
context 'as regular member' do
let!(:captain_membership) { create(:crew_membership, crew: crew, user: user, role: :member) }
it 'returns unauthorized' do
post "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}/assign",
params: { user_id: target_user.id },
headers: auth_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/crews/:crew_id/phantom_players/:id/confirm_claim' do
let(:claimer) { create(:user) }
let!(:claimer_membership) { create(:crew_membership, crew: crew, user: claimer, role: :member) }
let!(:phantom) { create(:phantom_player, crew: crew, claimed_by: claimer) }
let(:claimer_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: claimer.id, expires_in: 30.days, scopes: 'public')
end
let(:claimer_headers) { { 'Authorization' => "Bearer #{claimer_token.token}" } }
it 'confirms the claim' do
post "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}/confirm_claim",
headers: claimer_headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['phantom_player']['claim_confirmed']).to be true
end
it 'returns error for wrong user' do
post "/api/v1/crews/#{crew.id}/phantom_players/#{phantom.id}/confirm_claim",
headers: auth_headers
expect(response).to have_http_status(:forbidden)
json = JSON.parse(response.body)
expect(json['code']).to eq('not_claimed_by_user')
end
end
end