Merge branch 'codex/TUR-80-manual-team-action-lists' into 'master'

TUR-80 manual team action list creation

Closes TUR-80

See merge request turniere/turniere-backend!54
This commit is contained in:
Daniel Schädler 2026-04-23 21:01:55 +00:00
commit 84a7bc8ca9
17 changed files with 433 additions and 92 deletions

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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