From b40e64cde8f823d6e2c90c9b1bb48d23d8db7ac2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:47:51 +0200 Subject: [PATCH 1/3] 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 From d69e08a25b8cf848c857a08d9847863992052233 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:48:00 +0200 Subject: [PATCH 2/3] test: cover manual team action list flows --- spec/e2e/http/tournament_follow_sync_spec.rb | 39 +++++++++++++++++-- .../tournament_follow_sync_websocket_spec.rb | 20 +++++++++- spec/e2e/http/tournament_sync_test_spec.rb | 23 +++++++++-- ...nament_team_action_lists_websocket_spec.rb | 24 ++++++++++-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1802516..7fbd772 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -141,11 +141,29 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + expect(source.fetch(:team_action_lists)).to eq([]) + expect(follower.fetch(:team_action_lists)).to eq([]) + + log_phase!('create team action list on source and verify follower mirrors it') + created_list = create_team_action_list!(source.fetch(:id)) + expect(created_list.fetch(:key)).to eq(playoff_token_list_key) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(playoff_token_list(source)).not_to be_nil expect(playoff_token_list(follower)).not_to be_nil expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) .to match_array(playoff_token_list(source).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) + locked_list_create = follower_owner_client.post("/tournaments/#{follower.fetch(:id)}/team_action_lists", body: { + name: 'Follower created list', + key: 'follower_created_list', + action_name: 'Should fail', + source: 'all_tournament_teams' + }) + expect(locked_list_create[:status]).to eq(423) + expect(locked_list_create.dig(:json, :error)).to eq('Tournament is in read only follower mode') + follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team) locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' }) @@ -155,7 +173,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it') source_token_item = playoff_token_list(source).fetch(:team_action_items).first source_token_update = source_owner_client.patch( - "/tournaments/#{source.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{source_token_item.dig(:team, :id)}", + "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}", body: { completed: true } ) expect(source_token_update[:status]).to eq(200) @@ -167,7 +185,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name)) locked_token_update = follower_owner_client.patch( - "/tournaments/#{follower.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{follower_token_item.dig(:team, :id)}", + "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}", body: { completed: false } ) expect(locked_token_update[:status]).to eq(423) @@ -265,6 +283,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do response.fetch(:json) end + def create_team_action_list!(tournament_id) + response = source_owner_client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def seed_unrelated_tournaments!(client:, prefix:, range:) amount = rand(range) Array.new(amount) do |index| @@ -552,10 +581,14 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do def playoff_token_list(tournament) tournament.fetch(:team_action_lists, []).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end end + def playoff_token_list_key + 'playoff_token_collection' + end + def find_team_action_item(tournament, team_name:) playoff_token_list(tournament).fetch(:team_action_items).find do |item| item.dig(:team, :name) == team_name diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb index 15b5e6b..f748a93 100644 --- a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -37,6 +37,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do expect(configure_sync[:status]).to eq(200) finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id)) + create_team_action_list!(source.fetch(:id)) follower = wait_for_list_presence!(follower.fetch(:id)) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) @@ -106,6 +107,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do response.fetch(:json) end + def create_team_action_list!(tournament_id) + response = source_client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def finish_group_stage_and_create_playoffs!(source_id:) tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id) group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } @@ -161,13 +173,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end def team_action_item_completed?(payload, team_name:) - list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item.fetch('completed') end + + def playoff_token_list_key + 'playoff_token_collection' + end end diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 03ff7a2..1d82f59 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -63,6 +63,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do ) finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + create_team_action_list!(leader.fetch(:id)) leader_after_playoffs = fetch_tournament(leader.fetch(:id)) token_list = find_playoff_token_list!(leader_after_playoffs) @@ -73,7 +74,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(update_by_item_id[:status]).to eq(200) update_by_business_key = client.patch( - "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -94,6 +95,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do leader = create_group_stage_tournament(name_prefix: 'Missing Action Resource Leader') finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + create_team_action_list!(leader.fetch(:id)) leader_after_playoffs = fetch_tournament(leader.fetch(:id)) token_list = find_playoff_token_list!(leader_after_playoffs) @@ -109,7 +111,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(missing_list_update[:status]).to eq(404) missing_team_update = client.patch( - "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/999999999", + "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/999999999", body: { completed: true } ) expect(missing_team_update[:status]).to eq(404) @@ -179,6 +181,17 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(finish_stage[:status]).to eq(200) end + def create_team_action_list!(tournament_id) + response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def play_match!(match_id, first_score:, second_score:) match_response = anonymous_client.get("/matches/#{match_id}") expect(match_response[:status]).to eq(200) @@ -198,10 +211,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end + def playoff_token_list_key + 'playoff_token_collection' + end + def playoff_token_item_completed?(tournament, team_name:) list = find_playoff_token_list!(tournament) item = list.fetch(:team_action_items).find { |candidate| candidate.dig(:team, :name) == team_name } diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb index 4e21fec..d61149e 100644 --- a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -26,6 +26,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do it 'broadcasts initial and updated team action list state over websocket' do tournament = create_group_stage_tournament(name_prefix: 'Ws Leader') finish_group_stage_and_create_playoffs!(tournament.fetch(:id)) + create_team_action_list!(tournament.fetch(:id)) tournament = fetch_tournament(tournament.fetch(:id)) token_list = find_playoff_token_list!(tournament) @@ -33,7 +34,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id)) initial_payload = cable_client.wait_for_payload! - expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq('group_stage_survivor_playoff_tokens') + expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq(playoff_token_list_key) first_item = token_list.fetch(:team_action_items).first second_item = token_list.fetch(:team_action_items)[1] @@ -49,7 +50,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true) update_by_business_key = client.patch( - "/tournaments/#{tournament.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + "/tournaments/#{tournament.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -113,6 +114,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do expect(finish_stage[:status]).to eq(200) end + def create_team_action_list!(tournament_id) + response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def play_match!(match_id, first_score:, second_score:) match_response = anonymous_client.get("/matches/#{match_id}") expect(match_response[:status]).to eq(200) @@ -142,13 +154,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end def team_action_item_completed?(payload, team_name:) - list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item.fetch('completed') end + + def playoff_token_list_key + 'playoff_token_collection' + end end From d3589235d91fde7596be934bbb5ff8caa8d889d2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:48:11 +0200 Subject: [PATCH 3/3] docs: update manual team action list API --- AGENTS.md | 1 + doc/team_action_lists_api.md | 117 +++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a26dc23..bd49d17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,7 @@ Practical rule: For team action lists specifically: +- create lists explicitly through owner-facing HTTP API; do not auto-generate them from stage transitions unless product requirements say so - subscribe by tournament id - channel name is `TournamentTeamActionListsChannel` - broadcast full current list snapshot for that tournament diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 9c1fd25..9adeeb9 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -2,13 +2,19 @@ ## Purpose -Team action lists let the backend expose checklist-style workflows for teams inside a tournament. +Team action lists let tournament owners create checklist-style workflows for teams inside a tournament. -Current example: +Examples: -- `group_stage_survivor_playoff_tokens` +- collect playoff tokens +- track team check-in +- hand out medals -That list contains all teams that advanced from the group stage and tracks whether each team already collected a playoff token. +Important: + +- lists are not auto-generated by backend tournament events +- owner creates a list explicitly through API +- backend materializes the initial team items from the chosen `source` ## Read Flow @@ -26,7 +32,7 @@ Example response shape: { "id": 9, "name": "Playoff token collection", - "key": "group_stage_survivor_playoff_tokens", + "key": "playoff_token_collection", "action_name": "Collect playoff token", "source": "group_stage_survivors", "team_action_items": [ @@ -45,6 +51,50 @@ Example response shape: } ``` +## Create Flow + +Only tournament owner may create lists. +Follower tournaments in `read_only_mode` reject creation with `423`. + +Endpoint: + +- `POST /tournaments/:tournament_id/team_action_lists` + +Request body: + +```json +{ + "name": "Playoff token collection", + "key": "playoff_token_collection", + "action_name": "Collect playoff token", + "source": "group_stage_survivors" +} +``` + +Response: + +- `201 Created` +- response body is the created `TeamActionList` including `team_action_items` + +Current supported sources: + +- `all_tournament_teams` +- `group_stage_survivors` + +Current source behavior: + +- `all_tournament_teams` uses all teams currently in tournament +- `group_stage_survivors` uses teams currently advancing from a finished group stage + +Creation is snapshot-style: + +- backend resolves teams at create time +- backend creates one item per resolved team +- backend does not auto-create lists later +- backend does not auto-refresh existing list membership later + +If selected source currently resolves to no teams, backend returns `422`. + ## Write Flow Only tournament owner may change item status. @@ -79,7 +129,7 @@ Endpoint: Example: -- `PATCH /tournaments/123/team_action_lists/group_stage_survivor_playoff_tokens/teams/55` +- `PATCH /tournaments/123/team_action_lists/playoff_token_collection/teams/55` Request body: @@ -93,7 +143,12 @@ Response body for both write endpoints is the updated `TeamActionItem`. ## Idempotency Rules -These endpoints are designed to be idempotent if frontend sends explicit target state. +Create endpoint is idempotent by unique business key per tournament: + +- backend enforces unique `(tournament_id, key)` +- repeated create with same key returns `422`, not a second list + +Item update endpoints are idempotent if frontend sends explicit target state. Good: @@ -103,12 +158,12 @@ Good: Avoid: - toggle-style client behavior such as "invert current state" +- whole-list writeback from stale client state -Why this is idempotent: +Why item updates are idempotent: - sending `completed=true` multiple times leaves item in same final state - sending `completed=false` multiple times leaves item in same final state -- backend does not create duplicate items - backend stores one unique item per `(team_action_list_id, team_id)` `completed_at` behavior: @@ -122,10 +177,10 @@ Why this is idempotent: Recommended frontend flow: 1. Load tournament via `GET /tournaments/:id` -2. Find desired list by stable `key` -3. Render `team_action_items` -4. On user action, send explicit target state -5. Update local UI from response or refetch tournament +2. Render current `team_action_lists` +3. If owner wants a new checklist, send `POST /tournaments/:id/team_action_lists` +4. For item changes, send explicit target state for one item only +5. Update local UI from response or websocket snapshot Recommended frontend identifiers: @@ -135,6 +190,7 @@ Recommended frontend identifiers: Recommended optimistic UI: +- create list only once per intended key - set checkbox immediately to requested target state - revert if API returns error - do not compute next state from stale cached data if multiple clients may edit @@ -143,11 +199,13 @@ Recommended optimistic UI: Expected statuses: -- `200` update accepted +- `201` list created +- `200` item update accepted +- `401` unauthenticated - `403` authenticated but not tournament owner - `404` tournament, list, team, or item not found +- `422` invalid create payload, duplicate key, unsupported source, or source with no teams - `423` follower tournament is read-only -- `401` unauthenticated ## Sync / Follower Behavior @@ -155,10 +213,12 @@ Team action lists are included in tournament sync snapshots. That means: -- source tournament owner updates item state +- source owner creates list on source tournament - backend enqueues tournament sync -- follower tournament receives updated list state -- follower users can view but not mutate item state while read-only +- follower tournament receives created list and all items +- source owner updates item state +- follower tournament receives updated completion state +- follower users can view but not mutate list state while read-only ## Websocket / Live Update Behavior @@ -168,7 +228,7 @@ This is a websocket flow, not a webhook flow. - backend pushes updates to subscribed clients over websocket - frontend does not register callback URLs -- writes still happen over normal HTTP `PATCH` requests +- writes still happen over normal HTTP `POST` and `PATCH` requests Endpoint: @@ -190,11 +250,12 @@ Subscription identifier example: Behavior: - subscription immediately receives current full team-action-list snapshot for that tournament -- later list/item changes broadcast updated full snapshot again +- later list creation and item changes broadcast updated full snapshot again - same broadcast path is used for: - - direct source updates + - list creation + - direct item updates - follower sync imports -- both write endpoints trigger the same backend update path before broadcast: +- both item update endpoints trigger the same backend update path before broadcast: - `PATCH /team_action_items/:id` - `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id` @@ -208,7 +269,7 @@ Payload shape: { "id": 9, "name": "Playoff token collection", - "key": "group_stage_survivor_playoff_tokens", + "key": "playoff_token_collection", "action_name": "Collect playoff token", "source": "group_stage_survivors", "team_action_items": [ @@ -239,20 +300,22 @@ Practical frontend pattern: 1. load initial tournament state over HTTP 2. open websocket subscription for same tournament id 3. render incoming websocket payload as latest live snapshot -4. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only -5. let websocket keep other viewers in sync +4. if owner submits create form, send one HTTP `POST` to create the list +5. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only +6. let websocket keep other viewers in sync This avoids whole-list writebacks and reduces race-condition risk between multiple clients. ## Future Extension Guidance -When new lists are added, frontend should not hardcode backend internals beyond: +When new sources or list types are added, frontend should not hardcode backend internals beyond: - stable list `key` - user-facing `name` - user-facing `action_name` +- selected `source` -New list types should automatically fit the same frontend rendering model: +New list types should still fit same frontend rendering model: - list metadata - list items