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:
parent
a3a0138526
commit
4c8f4ffcf3
14 changed files with 738 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
31
app/blueprints/api/v1/phantom_player_blueprint.rb
Normal file
31
app/blueprints/api/v1/phantom_player_blueprint.rb
Normal 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
|
||||
79
app/controllers/api/v1/phantom_players_controller.rb
Normal file
79
app/controllers/api/v1/phantom_players_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
app/models/phantom_player.rb
Normal file
73
app/models/phantom_player.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
db/migrate/20251204074828_create_phantom_players.rb
Normal file
18
db/migrate/20251204074828_create_phantom_players.rb
Normal 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
|
||||
|
|
@ -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
|
||||
24
db/schema.rb
24
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_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"
|
||||
|
|
|
|||
34
spec/factories/phantom_players.rb
Normal file
34
spec/factories/phantom_players.rb
Normal 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
|
||||
162
spec/models/phantom_player_spec.rb
Normal file
162
spec/models/phantom_player_spec.rb
Normal 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
|
||||
223
spec/requests/api/v1/phantom_players_spec.rb
Normal file
223
spec/requests/api/v1/phantom_players_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue