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:
commit
84a7bc8ca9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue