From b40e64cde8f823d6e2c90c9b1bb48d23d8db7ac2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:47:51 +0200 Subject: [PATCH] feat: add manual team action list creation --- app/controllers/stages_controller.rb | 7 +- .../team_action_lists_controller.rb | 27 +++- app/models/team_action_list.rb | 1 + app/services/team_action_list_service.rb | 58 +++++---- config/routes.rb | 1 + spec/controllers/stages_controller_spec.rb | 9 +- .../team_action_lists_controller_spec.rb | 115 ++++++++++++++++-- .../tournaments_controller_spec.rb | 2 +- spec/factories/team_action_lists.rb | 4 +- .../routing/team_action_lists_routing_spec.rb | 9 +- .../services/team_action_list_service_spec.rb | 68 +++++++++++ 11 files changed, 248 insertions(+), 53 deletions(-) create mode 100644 spec/services/team_action_list_service_spec.rb diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index 5cada55..fcdac4d 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -65,11 +65,8 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end - return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? - - TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament) - return + return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: GroupStageService.get_advancing_teams(@stage)).success? render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity raise ActiveRecord::Rollback diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index 6f07f5c..9938fa9 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -2,12 +2,29 @@ class TeamActionListsController < ApplicationController before_action :set_tournament - before_action :authenticate_user!, only: %i[update_item] - before_action -> { require_owner! @tournament.owner }, only: %i[update_item] - before_action -> { require_writable_tournament!(@tournament) }, only: %i[update_item] + before_action :authenticate_user!, only: %i[create update_item] + before_action -> { require_owner! @tournament.owner }, only: %i[create update_item] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[create update_item] before_action :set_team_action_item, only: %i[update_item] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error + # POST /tournaments/:tournament_id/team_action_lists + def create + team_action_list = TeamActionListService.create!( + tournament: @tournament, + name: team_action_list_params.fetch(:name), + key: team_action_list_params.fetch(:key), + action_name: team_action_list_params.fetch(:action_name), + source: team_action_list_params.fetch(:source) + ) + + render json: team_action_list, status: :created + rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e + render json: { error: e.message }, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_entity + end + # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id def update_item if TeamActionItemUpdate.call( @@ -34,4 +51,8 @@ class TeamActionListsController < ApplicationController def team_action_item_params params.slice(:completed).permit(:completed) end + + def team_action_list_params + params.permit(:name, :key, :action_name, :source) + end end diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb index 1cbedcf..018bd0a 100644 --- a/app/models/team_action_list.rb +++ b/app/models/team_action_list.rb @@ -6,6 +6,7 @@ class TeamActionList < ApplicationRecord validates :name, :key, :action_name, :source, presence: true validates :key, uniqueness: { scope: :tournament_id } + validates :key, format: { with: /\A[a-z0-9_]+\z/, message: 'may only contain lowercase letters, numbers, and underscores' } delegate :owner, to: :tournament diff --git a/app/services/team_action_list_service.rb b/app/services/team_action_list_service.rb index e613ee3..11b66e0 100644 --- a/app/services/team_action_list_service.rb +++ b/app/services/team_action_list_service.rb @@ -1,34 +1,48 @@ # frozen_string_literal: true class TeamActionListService - GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens' + SUPPORTED_SOURCES = %w[all_tournament_teams group_stage_survivors].freeze + + class InvalidSourceError < StandardError; end + class EmptySourceError < StandardError; end class << self - def ensure_group_stage_survivor_playoff_token_list!(tournament) - group_stage = tournament.group_stage - return nil unless group_stage&.finished? + def create!(tournament:, name:, key:, action_name:, source:) + teams = teams_for_source!(tournament:, source:) + raise EmptySourceError, "Source #{source} does not currently contain any teams" if teams.empty? - teams = tournament.teams_advancing_from_group_stage - return nil if teams.empty? + list = nil + TeamActionList.transaction do + list = tournament.team_action_lists.new( + name:, + key:, + action_name:, + source: + ) + list.save! + list.sync_teams!(teams) + end - ensure_list!( - tournament: tournament, - key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY, - name: 'Playoff token collection', - action_name: 'Collect playoff token', - source: 'group_stage_survivors', - teams: teams - ) + TournamentSyncEnqueue.call(tournament) + list end - def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:) - list = tournament.team_action_lists.find_or_initialize_by(key: key) - list.name = name - list.action_name = action_name - list.source = source - list.save! - list.sync_teams!(teams) - list + def source_keys + SUPPORTED_SOURCES + end + + def teams_for_source!(tournament:, source:) + case source + when 'all_tournament_teams' + tournament.teams.to_a + when 'group_stage_survivors' + group_stage = tournament.group_stage + return [] unless group_stage&.finished? + + tournament.teams_advancing_from_group_stage + else + raise InvalidSourceError, "Unsupported team action list source: #{source}" + end end end end diff --git a/config/routes.rb b/config/routes.rb index 75de6ab..9c53ca4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,7 @@ Rails.application.routes.draw do resources :teams, only: %i[show update] resources :team_action_items, only: %i[update] resources :tournaments do + resources :team_action_lists, only: %i[create] patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] resources :matches, only: %i[index] diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 259427f..b9e7c2b 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -92,14 +92,9 @@ RSpec.describe StagesController, type: :controller do .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) end - it 'creates playoff token collection list for advancing teams' do + it 'does not auto-create team action lists' do tournament = running_group_stage.tournament.reload - list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY) - - expect(list.name).to eq('Playoff token collection') - expect(list.team_action_items.includes(:team).map(&:team)) - .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) - expect(list.team_action_items.pluck(:completed).uniq).to eq([false]) + expect(tournament.team_action_lists).to be_empty end end diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb index 2507fd8..672d321 100644 --- a/spec/controllers/team_action_lists_controller_spec.rb +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -3,20 +3,113 @@ require 'rails_helper' RSpec.describe TeamActionListsController, type: :controller do + let(:tournament) { create(:tournament, teams_count: 4) } let(:team_action_item) { create(:team_action_item) } - let(:tournament) { team_action_item.tournament } let(:list) { team_action_item.team_action_list } let(:team) { team_action_item.team } - describe 'PATCH #update_item' do + describe 'POST #create' do + let(:create_params) do + { + tournament_id: tournament.to_param, + name: 'Playoff token collection', + key: 'playoff_token_collection', + action_name: 'Collect playoff token', + source: 'all_tournament_teams' + } + end + context 'as owner' do before do apply_authentication_headers_for tournament.owner end + it 'creates a list populated from the selected source' do + post :create, params: create_params + + expect(response).to have_http_status(:created) + + created_list = tournament.team_action_lists.find_by!(key: 'playoff_token_collection') + expect(created_list.name).to eq('Playoff token collection') + expect(created_list.action_name).to eq('Collect playoff token') + expect(created_list.source).to eq('all_tournament_teams') + expect(created_list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams) + end + + it 'enqueues tournament sync after create' do + allow(TournamentSyncEnqueue).to receive(:call) + + post :create, params: create_params + + expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) + end + + it 'returns unprocessable entity for unsupported source' do + post :create, params: create_params.merge(source: 'unknown_source') + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source') + end + + it 'returns unprocessable entity when selected source resolves to no teams' do + empty_tournament = create(:tournament, teams_count: 0) + apply_authentication_headers_for empty_tournament.owner + + post :create, params: create_params.merge(tournament_id: empty_tournament.to_param) + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams') + end + + it 'returns unprocessable entity for duplicate key' do + tournament.team_action_lists.create!( + name: 'Existing list', + key: 'playoff_token_collection', + action_name: 'Existing action', + source: 'all_tournament_teams' + ) + + post :create, params: create_params + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'returns forbidden' do + post :create, params: create_params + + expect(response).to have_http_status(:forbidden) + end + end + + context 'for follower tournament' do + before do + tournament.update!(read_only_mode: true, sync_auth_token: 'sync-token') + apply_authentication_headers_for tournament.owner + end + + it 'returns locked' do + post :create, params: create_params + + expect(response).to have_http_status(:locked) + end + end + end + + describe 'PATCH #update_item' do + context 'as owner' do + before do + apply_authentication_headers_for list.tournament.owner + end + it 'updates item by list key and team id' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -29,7 +122,7 @@ RSpec.describe TeamActionListsController, type: :controller do it 'is idempotent when same state sent repeatedly' do 2.times do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -44,13 +137,13 @@ RSpec.describe TeamActionListsController, type: :controller do allow(TournamentSyncEnqueue).to receive(:call) patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true } - expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) + expect(TournamentSyncEnqueue).to have_received(:call).with(list.tournament) end end @@ -61,7 +154,7 @@ RSpec.describe TeamActionListsController, type: :controller do it 'returns forbidden' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -73,12 +166,12 @@ RSpec.describe TeamActionListsController, type: :controller do context 'when list key is missing' do before do - apply_authentication_headers_for tournament.owner + apply_authentication_headers_for list.tournament.owner end it 'returns not found' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: 'unknown_key', team_id: team.to_param, completed: true @@ -90,12 +183,12 @@ RSpec.describe TeamActionListsController, type: :controller do context 'when team is missing from list context' do before do - apply_authentication_headers_for tournament.owner + apply_authentication_headers_for list.tournament.owner end it 'returns not found' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: 999_999_999, completed: true diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 6939459..65d4dfd 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -214,7 +214,7 @@ RSpec.describe TournamentsController, type: :controller do body = deserialize_response response expect(body[:team_action_lists].size).to eq(1) - expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens') + expect(body[:team_action_lists].first[:key]).to eq('playoff_token_collection') expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true) end end diff --git a/spec/factories/team_action_lists.rb b/spec/factories/team_action_lists.rb index 12e240c..a4d335b 100644 --- a/spec/factories/team_action_lists.rb +++ b/spec/factories/team_action_lists.rb @@ -4,8 +4,8 @@ FactoryBot.define do factory :team_action_list do tournament name { 'Playoff token collection' } - key { 'group_stage_survivor_playoff_tokens' } + key { 'playoff_token_collection' } action_name { 'Collect playoff token' } - source { 'group_stage_survivors' } + source { 'all_tournament_teams' } end end diff --git a/spec/routing/team_action_lists_routing_spec.rb b/spec/routing/team_action_lists_routing_spec.rb index 121778e..55785b0 100644 --- a/spec/routing/team_action_lists_routing_spec.rb +++ b/spec/routing/team_action_lists_routing_spec.rb @@ -4,12 +4,17 @@ require 'rails_helper' RSpec.describe TeamActionListsController, type: :routing do describe 'routing' do + it 'routes to #create via POST' do + expect(post: '/tournaments/1/team_action_lists') + .to route_to('team_action_lists#create', tournament_id: '1') + end + it 'routes to #update_item via PATCH' do - expect(patch: '/tournaments/1/team_action_lists/group_stage_survivor_playoff_tokens/teams/2') + expect(patch: '/tournaments/1/team_action_lists/playoff_token_collection/teams/2') .to route_to( 'team_action_lists#update_item', tournament_id: '1', - key: 'group_stage_survivor_playoff_tokens', + key: 'playoff_token_collection', team_id: '2' ) end diff --git a/spec/services/team_action_list_service_spec.rb b/spec/services/team_action_list_service_spec.rb new file mode 100644 index 0000000..f71d4a1 --- /dev/null +++ b/spec/services/team_action_list_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamActionListService do + describe '.create!' do + let(:tournament) { create(:tournament, teams_count: 4) } + + it 'creates a list with items for all tournament teams' do + list = described_class.create!( + tournament:, + name: 'Team check-in', + key: 'team_check_in', + action_name: 'Check in', + source: 'all_tournament_teams' + ) + + expect(list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams) + end + + it 'creates a list for group stage survivors when available' do + group_stage_tournament = create(:tournament, teams_count: 0, playoff_teams_amount: 2, instant_finalists_amount: 2) + group_stage = create(:group_stage, tournament: group_stage_tournament, group_count: 0) + group = create(:group, stage: group_stage, match_count: 0, number: 1) + team_a = create(:team, tournament: group_stage_tournament, name: 'A') + team_b = create(:team, tournament: group_stage_tournament, name: 'B') + create(:group_score, group:, team: team_a, group_points: 6, scored_points: 6, received_points: 0, position: 1) + create(:group_score, group:, team: team_b, group_points: 3, scored_points: 3, received_points: 3, position: 2) + group_stage.update!(state: 'finished') + + list = described_class.create!( + tournament: group_stage_tournament, + name: 'Playoff token collection', + key: 'playoff_token_collection', + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + ) + + expect(list.team_action_items.includes(:team).map(&:team)).to match_array([team_a, team_b]) + end + + it 'raises for unsupported source' do + expect do + described_class.create!( + tournament:, + name: 'Broken', + key: 'broken', + action_name: 'Break', + source: 'unknown_source' + ) + end.to raise_error(TeamActionListService::InvalidSourceError, 'Unsupported team action list source: unknown_source') + end + + it 'raises when source resolves to no teams' do + empty_tournament = create(:tournament, teams_count: 0) + + expect do + described_class.create!( + tournament: empty_tournament, + name: 'Empty', + key: 'empty', + action_name: 'Nothing', + source: 'all_tournament_teams' + ) + end.to raise_error(TeamActionListService::EmptySourceError, 'Source all_tournament_teams does not currently contain any teams') + end + end +end