feat: add manual team action list creation

This commit is contained in:
Daniel Schädler 2026-04-23 22:47:51 +02:00
parent 7f795b765d
commit b40e64cde8
11 changed files with 248 additions and 53 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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