From 4c8f4ffcf3131793b5f0866355d50d2d6f965fb3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 23:55:15 -0800 Subject: [PATCH] add phantom players for non-registered crew members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/v1/gw_individual_score_blueprint.rb | 18 ++ .../api/v1/phantom_player_blueprint.rb | 31 +++ .../api/v1/phantom_players_controller.rb | 79 +++++++ app/errors/crew_errors.rb | 28 +++ app/models/crew.rb | 1 + app/models/gw_individual_score.rb | 36 +++ app/models/phantom_player.rb | 73 ++++++ config/routes.rb | 7 + .../20251204074828_create_phantom_players.rb | 18 ++ ..._phantom_player_to_gw_individual_scores.rb | 5 + db/schema.rb | 24 +- spec/factories/phantom_players.rb | 34 +++ spec/models/phantom_player_spec.rb | 162 +++++++++++++ spec/requests/api/v1/phantom_players_spec.rb | 223 ++++++++++++++++++ 14 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/api/v1/phantom_player_blueprint.rb create mode 100644 app/controllers/api/v1/phantom_players_controller.rb create mode 100644 app/models/phantom_player.rb create mode 100644 db/migrate/20251204074828_create_phantom_players.rb create mode 100644 db/migrate/20251204075226_add_phantom_player_to_gw_individual_scores.rb create mode 100644 spec/factories/phantom_players.rb create mode 100644 spec/models/phantom_player_spec.rb create mode 100644 spec/requests/api/v1/phantom_players_spec.rb diff --git a/app/blueprints/api/v1/gw_individual_score_blueprint.rb b/app/blueprints/api/v1/gw_individual_score_blueprint.rb index a80c865..d024d8b 100644 --- a/app/blueprints/api/v1/gw_individual_score_blueprint.rb +++ b/app/blueprints/api/v1/gw_individual_score_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/phantom_player_blueprint.rb b/app/blueprints/api/v1/phantom_player_blueprint.rb new file mode 100644 index 0000000..19a7432 --- /dev/null +++ b/app/blueprints/api/v1/phantom_player_blueprint.rb @@ -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 diff --git a/app/controllers/api/v1/phantom_players_controller.rb b/app/controllers/api/v1/phantom_players_controller.rb new file mode 100644 index 0000000..351b8bf --- /dev/null +++ b/app/controllers/api/v1/phantom_players_controller.rb @@ -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 diff --git a/app/errors/crew_errors.rb b/app/errors/crew_errors.rb index 819ddda..7c437d6 100644 --- a/app/errors/crew_errors.rb +++ b/app/errors/crew_errors.rb @@ -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 diff --git a/app/models/crew.rb b/app/models/crew.rb index d1cbc66..915c14a 100644 --- a/app/models/crew.rb +++ b/app/models/crew.rb @@ -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 diff --git a/app/models/gw_individual_score.rb b/app/models/gw_individual_score.rb index f52c2a8..1e58f71 100644 --- a/app/models/gw_individual_score.rb +++ b/app/models/gw_individual_score.rb @@ -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 diff --git a/app/models/phantom_player.rb b/app/models/phantom_player.rb new file mode 100644 index 0000000..93bbcb0 --- /dev/null +++ b/app/models/phantom_player.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 7c8613c..6dbf4a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20251204074828_create_phantom_players.rb b/db/migrate/20251204074828_create_phantom_players.rb new file mode 100644 index 0000000..f25c854 --- /dev/null +++ b/db/migrate/20251204074828_create_phantom_players.rb @@ -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 diff --git a/db/migrate/20251204075226_add_phantom_player_to_gw_individual_scores.rb b/db/migrate/20251204075226_add_phantom_player_to_gw_individual_scores.rb new file mode 100644 index 0000000..571328c --- /dev/null +++ b/db/migrate/20251204075226_add_phantom_player_to_gw_individual_scores.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index fd3d4ea..79d2f6a 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_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" diff --git a/spec/factories/phantom_players.rb b/spec/factories/phantom_players.rb new file mode 100644 index 0000000..7f00027 --- /dev/null +++ b/spec/factories/phantom_players.rb @@ -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 diff --git a/spec/models/phantom_player_spec.rb b/spec/models/phantom_player_spec.rb new file mode 100644 index 0000000..b302837 --- /dev/null +++ b/spec/models/phantom_player_spec.rb @@ -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 diff --git a/spec/requests/api/v1/phantom_players_spec.rb b/spec/requests/api/v1/phantom_players_spec.rb new file mode 100644 index 0000000..fc1c3af --- /dev/null +++ b/spec/requests/api/v1/phantom_players_spec.rb @@ -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