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

TUR-80 playoff token collection tracking

Closes TUR-80

See merge request turniere/turniere-backend!49
This commit is contained in:
Daniel Schädler 2026-04-23 11:46:41 +00:00
commit e41ef870f9
41 changed files with 1513 additions and 6 deletions

View File

@ -52,6 +52,22 @@ Examples:
- `inv docker-test-shard --node-index=3 --node-total=8` - `inv docker-test-shard --node-index=3 --node-total=8`
- `inv docker-test-http-e2e` - `inv docker-test-http-e2e`
## Concurrent Local Testing
If other agents or local runs may already be using shared test resources, do not
reuse `db/test.sqlite3` or fixed ports for ad-hoc verification.
Prefer isolated per-run resources instead:
- use a unique temporary sqlite database via `DATABASE_URL=sqlite3:/tmp/...`
- choose open ports automatically or derive unique ports per run
- keep normal repo tasks as default, but use isolated overrides when avoiding collisions is important
Practical expectation:
- for targeted/spec-specific verification where `inv` does not provide isolation knobs, it is acceptable to run the underlying command with isolated env vars
- if isolated local verification becomes recurring, add or extend an `inv` task for it
For local E2E-style startup with a usable login, prefer: For local E2E-style startup with a usable login, prefer:
- `inv start-local --bootstrap-user` - `inv start-local --bootstrap-user`
@ -147,6 +163,27 @@ That means:
- new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite - new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite
- the released production image should be exercised through the blackbox task layer, not only Rails-internal test entrypoints - the released production image should be exercised through the blackbox task layer, not only Rails-internal test entrypoints
## Websocket Live Updates
Team-action-list live updates use Rails ActionCable at `/cable`.
This is websocket push from backend to clients, not a webhook callback model.
Practical rule:
- if a backend feature needs live frontend updates, prefer one shared broadcast path for all mutation sources
- do not duplicate separate "HTTP update logic" and "websocket update logic"
- model/service callbacks should fan out websocket broadcasts so normal writes and follower-sync imports both trigger same live update behavior
For team action lists specifically:
- subscribe by tournament id
- channel name is `TournamentTeamActionListsChannel`
- broadcast full current list snapshot for that tournament
- event payload type is `team_action_lists.updated`
- keep write APIs idempotent and item-scoped
- keep websocket read-only; item state changes still go through HTTP `PATCH`
- cover both direct-app websocket updates and follower-sync websocket propagation in E2E
## Practical Expectation ## Practical Expectation
If you touch test, verification, boot, Docker, or scenario setup workflows, If you touch test, verification, boot, Docker, or scenario setup workflows,

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class TournamentTeamActionListsChannel < ApplicationCable::Channel
def subscribed
tournament = Tournament.find(params[:tournament_id])
stream_for tournament
transmit TeamActionListBroadcastPayload.build(tournament)
end
end

View File

@ -65,8 +65,11 @@ class StagesController < ApplicationController
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
teams: GroupStageService.get_advancing_teams(@stage)).success? teams: GroupStageService.get_advancing_teams(@stage)).success?
TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament)
return
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
raise ActiveRecord::Rollback raise ActiveRecord::Rollback

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class TeamActionItemsController < ApplicationController
before_action :set_team_action_item, only: %i[update]
before_action :authenticate_user!, only: %i[update]
before_action -> { require_owner! @team_action_item.owner }, only: %i[update]
before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update]
# PATCH /team_action_items/1
def update
if TeamActionItemUpdate.call(
team_action_item: @team_action_item,
completed: team_action_item_params.fetch(:completed)
)
render json: @team_action_item
else
render json: @team_action_item.errors, status: :unprocessable_entity
end
end
private
def set_team_action_item
@team_action_item = TeamActionItem.find(params[:id])
end
def team_action_item_params
params.slice(:completed).permit(:completed)
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
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 :set_team_action_item, only: %i[update_item]
# PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id
def update_item
if TeamActionItemUpdate.call(
team_action_item: @team_action_item,
completed: team_action_item_params.fetch(:completed)
)
render json: @team_action_item
else
render json: @team_action_item.errors, status: :unprocessable_entity
end
end
private
def set_tournament
@tournament = Tournament.find(params[:tournament_id])
end
def set_team_action_item
list = @tournament.team_action_lists.find_by!(key: params[:key])
@team_action_item = list.team_action_items.find_by!(team_id: params[:team_id])
end
def team_action_item_params
params.slice(:completed).permit(:completed)
end
end

View File

@ -5,6 +5,7 @@ class Team < ApplicationRecord
has_many :group_scores, dependent: :destroy has_many :group_scores, dependent: :destroy
has_many :match_scores, dependent: :destroy has_many :match_scores, dependent: :destroy
has_many :bets, dependent: :destroy has_many :bets, dependent: :destroy
has_many :team_action_items, dependent: :destroy
validates :name, presence: true validates :name, presence: true

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
class TeamActionItem < ApplicationRecord
belongs_to :team_action_list
belongs_to :team
validates :team_id, uniqueness: { scope: :team_action_list_id }
delegate :tournament, :owner, to: :team_action_list
before_validation :normalize_completed_at
before_destroy :store_tournament_id_for_broadcast
after_commit :broadcast_state_change, on: %i[create update destroy]
private
def normalize_completed_at
self.completed_at = completed? ? (completed_at || Time.current) : nil
end
def store_tournament_id_for_broadcast
@broadcast_tournament_id = team_action_list.tournament_id
end
def broadcast_state_change
TeamActionListBroadcast.call(@broadcast_tournament_id || team_action_list.tournament_id)
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class TeamActionList < ApplicationRecord
belongs_to :tournament
has_many :team_action_items, dependent: :destroy
validates :name, :key, :action_name, :source, presence: true
validates :key, uniqueness: { scope: :tournament_id }
delegate :owner, to: :tournament
before_destroy :store_tournament_id_for_broadcast
after_commit :broadcast_state_change, on: %i[create update destroy]
def sync_teams!(teams)
desired_team_ids = teams.map(&:id).uniq
transaction do
team_action_items.where.not(team_id: desired_team_ids).destroy_all
desired_team_ids.each do |team_id|
team_action_items.find_or_create_by!(team_id: team_id)
end
end
end
private
def store_tournament_id_for_broadcast
@broadcast_tournament_id = tournament_id
end
def broadcast_state_change
TeamActionListBroadcast.call(@broadcast_tournament_id || tournament_id)
end
end

View File

@ -7,6 +7,7 @@ class Tournament < ApplicationRecord
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy has_many :stages, dependent: :destroy
has_many :beamers, dependent: :destroy has_many :beamers, dependent: :destroy
has_many :team_action_lists, dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class TeamActionItemSerializer < ApplicationSerializer
attributes :completed, :completed_at, :team
attribute :completed_at do
object.completed_at&.iso8601
end
attribute :team do
{
id: object.team.id,
name: object.team.name
}
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class TeamActionListSerializer < ApplicationSerializer
attributes :name, :key, :action_name, :source
has_many :team_action_items
end

View File

@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
has_many :stages has_many :stages
has_many :team_action_lists
attribute :timer_end do attribute :timer_end do
object.timer_end&.iso8601 object.timer_end&.iso8601

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class TeamActionItemUpdate
class << self
def call(team_action_item:, completed:)
new(team_action_item: team_action_item, completed: completed).call
end
end
def initialize(team_action_item:, completed:)
@team_action_item = team_action_item
@completed = completed
end
def call
return false unless team_action_item.update(completed: completed)
TournamentSyncEnqueue.call(team_action_item.tournament)
true
end
private
attr_reader :team_action_item, :completed
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class TeamActionListBroadcast
class << self
def call(tournament_id)
tournament = Tournament.includes(team_action_lists: { team_action_items: :team }).find_by(id: tournament_id)
return if tournament.nil?
TournamentTeamActionListsChannel.broadcast_to(
tournament,
TeamActionListBroadcastPayload.build(tournament)
)
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class TeamActionListBroadcastPayload
class << self
def build(tournament)
{
type: 'team_action_lists.updated',
tournament_id: tournament.id,
team_action_lists: serialized_lists(tournament)
}
end
private
def serialized_lists(tournament)
ActiveModelSerializers::SerializableResource.new(
tournament.team_action_lists.includes(team_action_items: :team).order(:id),
each_serializer: TeamActionListSerializer
).as_json
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class TeamActionListService
GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens'
class << self
def ensure_group_stage_survivor_playoff_token_list!(tournament)
group_stage = tournament.group_stage
return nil unless group_stage&.finished?
teams = tournament.teams_advancing_from_group_stage
return nil if teams.empty?
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
)
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
end
end
end

View File

@ -9,6 +9,8 @@ class TournamentSnapshotBuilder
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze
MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze
GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze
TEAM_ACTION_LIST_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionList).map(&:to_sym).freeze
TEAM_ACTION_ITEM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionItem).map(&:to_sym).freeze
def self.build(tournament) def self.build(tournament)
new(tournament).build new(tournament).build
@ -29,6 +31,8 @@ class TournamentSnapshotBuilder
payload[:matches] = matches_payload payload[:matches] = matches_payload
payload[:match_scores] = match_scores_payload payload[:match_scores] = match_scores_payload
payload[:group_scores] = group_scores_payload payload[:group_scores] = group_scores_payload
payload[:team_action_lists] = team_action_lists_payload
payload[:team_action_items] = team_action_items_payload
payload payload
end end
@ -78,6 +82,20 @@ class TournamentSnapshotBuilder
end end
end end
def team_action_lists_payload
tournament.team_action_lists.order(:id).map do |team_action_list|
team_action_list.attributes.symbolize_keys.slice(*TEAM_ACTION_LIST_ATTRIBUTES)
end
end
def team_action_items_payload
tournament.team_action_lists.flat_map(&:team_action_items).sort_by(&:id).map do |team_action_item|
attrs = team_action_item.attributes.symbolize_keys.slice(*TEAM_ACTION_ITEM_ATTRIBUTES)
attrs[:completed_at] = team_action_item.completed_at&.iso8601
attrs
end
end
def teams_for_snapshot def teams_for_snapshot
( (
tournament.teams.to_a + tournament.teams.to_a +

View File

@ -32,6 +32,8 @@ class TournamentSnapshotImporter
existing_matches = tournament.matches existing_matches = tournament.matches
existing_match_scores = existing_matches.flat_map(&:match_scores) existing_match_scores = existing_matches.flat_map(&:match_scores)
existing_group_scores = existing_groups.flat_map(&:group_scores) existing_group_scores = existing_groups.flat_map(&:group_scores)
existing_team_action_lists = tournament.team_action_lists.to_a
existing_team_action_items = existing_team_action_lists.flat_map(&:team_action_items)
update_tournament! update_tournament!
teams_by_source_id = sync_teams! teams_by_source_id = sync_teams!
@ -41,7 +43,11 @@ class TournamentSnapshotImporter
matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id) matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id)
sync_match_scores!(matches_by_source_id, teams_by_source_id) sync_match_scores!(matches_by_source_id, teams_by_source_id)
sync_group_scores!(groups_by_source_id, teams_by_source_id) sync_group_scores!(groups_by_source_id, teams_by_source_id)
team_action_lists_by_source_id = sync_team_action_lists!
sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
destroy_stale_records(existing_team_action_items, snapshot.fetch(:team_action_items, []))
destroy_stale_records(existing_team_action_lists, snapshot.fetch(:team_action_lists, []))
destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, [])) destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, []))
destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, [])) destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, []))
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, [])) destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
@ -162,6 +168,33 @@ class TournamentSnapshotImporter
end end
end end
def sync_team_action_lists!
sync_records(
model_class: TeamActionList,
existing_records: tournament.team_action_lists.to_a,
payload: snapshot.fetch(:team_action_lists, [])
) do |team_action_list, attrs|
team_action_list.tournament = tournament
team_action_list.name = attrs.fetch(:name)
team_action_list.key = attrs.fetch(:key)
team_action_list.action_name = attrs.fetch(:action_name)
team_action_list.source = attrs.fetch(:source)
end
end
def sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
sync_records(
model_class: TeamActionItem,
existing_records: tournament.team_action_lists.flat_map(&:team_action_items),
payload: snapshot.fetch(:team_action_items, [])
) do |team_action_item, attrs|
team_action_item.team_action_list = team_action_lists_by_source_id.fetch(attrs.fetch(:team_action_list_id))
team_action_item.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
team_action_item.completed = attrs.fetch(:completed)
team_action_item.completed_at = parse_time(attrs[:completed_at])
end
end
def sync_records(model_class:, existing_records:, payload:) def sync_records(model_class:, existing_records:, payload:)
existing_by_source_id = existing_records.index_by(&:sync_source_id) existing_by_source_id = existing_records.index_by(&:sync_source_id)

View File

@ -54,6 +54,14 @@ class TournamentSyncSchema
GroupScore => { GroupScore => {
synced: %w[id group_points scored_points received_points team_id group_id position].freeze, synced: %w[id group_points scored_points received_points team_id group_id position].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze,
TeamActionList => {
synced: %w[id name key action_name source].freeze,
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
}.freeze,
TeamActionItem => {
synced: %w[id team_action_list_id team_id completed completed_at].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze }.freeze
}.freeze }.freeze

View File

@ -9,9 +9,9 @@ require 'active_job/railtie'
require 'active_record/railtie' require 'active_record/railtie'
require 'active_storage/engine' require 'active_storage/engine'
require 'action_controller/railtie' require 'action_controller/railtie'
require 'action_cable/engine'
require 'action_mailer/railtie' require 'action_mailer/railtie'
require 'action_view/railtie' require 'action_view/railtie'
# require "action_cable/engine"
# require "sprockets/railtie" # require "sprockets/railtie"
require 'rails/test_unit/railtie' require 'rails/test_unit/railtie'
@ -34,6 +34,8 @@ module TurniereBackend
# Middleware like session, flash, cookies can be added back manually. # Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource. # Skip views, helpers and assets when generating a new resource.
config.api_only = true config.api_only = true
config.action_cable.disable_request_forgery_protection = true
config.action_cable.allowed_request_origins = [%r{\Ahttps?://.*\z}]
config.middleware.insert_before 0, Rack::Cors do config.middleware.insert_before 0, Rack::Cors do
allow do allow do

8
config/cable.yml Normal file
View File

@ -0,0 +1,8 @@
development:
adapter: async
test:
adapter: async
production:
adapter: async

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.routes.draw do Rails.application.routes.draw do
mount ActionCable.server => '/cable'
mount_devise_token_auth_for 'User', at: 'users', controllers: { mount_devise_token_auth_for 'User', at: 'users', controllers: {
registrations: 'overrides/registrations', registrations: 'overrides/registrations',
sessions: 'overrides/sessions' sessions: 'overrides/sessions'
@ -17,7 +19,9 @@ Rails.application.routes.draw do
end end
resources :stages, only: %i[show update] resources :stages, only: %i[show update]
resources :teams, only: %i[show update] resources :teams, only: %i[show update]
resources :team_action_items, only: %i[update]
resources :tournaments do resources :tournaments do
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index]
resources :beamers, only: %i[index show create update destroy] do resources :beamers, only: %i[index show create update destroy] do

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateTeamActionLists < ActiveRecord::Migration[7.0]
def change
create_table :team_action_lists do |t|
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true
t.string :name, null: false
t.string :key, null: false
t.string :action_name, null: false
t.string :source, null: false
t.bigint :sync_source_id, index: true
t.timestamps
end
add_index :team_action_lists, [:tournament_id, :key], unique: true
create_table :team_action_items do |t|
t.references :team_action_list, null: false, foreign_key: { on_delete: :cascade }, index: true
t.references :team, null: false, foreign_key: { on_delete: :cascade }, index: true
t.boolean :completed, null: false, default: false
t.datetime :completed_at
t.bigint :sync_source_id, index: true
t.timestamps
end
add_index :team_action_items, [:team_action_list_id, :team_id], unique: true
end
end

View File

@ -0,0 +1,260 @@
# Team Action Lists API
## Purpose
Team action lists let the backend expose checklist-style workflows for teams inside a tournament.
Current example:
- `group_stage_survivor_playoff_tokens`
That list contains all teams that advanced from the group stage and tracks whether each team already collected a playoff token.
## Read Flow
Frontend reads team action lists from the normal tournament payload:
- `GET /tournaments/:id`
Example response shape:
```json
{
"id": 123,
"name": "My Tournament",
"team_action_lists": [
{
"id": 9,
"name": "Playoff token collection",
"key": "group_stage_survivor_playoff_tokens",
"action_name": "Collect playoff token",
"source": "group_stage_survivors",
"team_action_items": [
{
"id": 41,
"completed": false,
"completed_at": null,
"team": {
"id": 55,
"name": "Team A"
}
}
]
}
]
}
```
## Write Flow
Only tournament owner may change item status.
Everyone may read status.
Follower tournaments in `read_only_mode` reject updates with `423`.
There are two supported write patterns.
### Option 1: update by item id
Use this when frontend already has the item id from tournament payload.
- `PATCH /team_action_items/:id`
Request body:
```json
{
"completed": true
}
```
### Option 2: update by list key + team id
Use this when frontend wants a business-key style request like:
- "tick off team X from list Y in tournament Z"
Endpoint:
- `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id`
Example:
- `PATCH /tournaments/123/team_action_lists/group_stage_survivor_playoff_tokens/teams/55`
Request body:
```json
{
"completed": true
}
```
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.
Good:
- send `{"completed": true}` to mark complete
- send `{"completed": false}` to mark incomplete
Avoid:
- toggle-style client behavior such as "invert current state"
Why this is 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:
- first successful transition to `completed=true` sets timestamp if none exists
- sending `completed=true` again keeps same completed state
- sending `completed=false` clears `completed_at`
## Frontend Implementation Guidance
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
Recommended frontend identifiers:
- use list `key` as stable frontend reference
- use `team.id` as stable team reference
- use `team_action_item.id` when available for direct item updates
Recommended optimistic UI:
- 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
## Error Handling
Expected statuses:
- `200` update accepted
- `403` authenticated but not tournament owner
- `404` tournament, list, team, or item not found
- `423` follower tournament is read-only
- `401` unauthenticated
## Sync / Follower Behavior
Team action lists are included in tournament sync snapshots.
That means:
- source tournament owner updates item state
- backend enqueues tournament sync
- follower tournament receives updated list state
- follower users can view but not mutate item state while read-only
## Websocket / Live Update Behavior
Team action lists also support live updates over ActionCable.
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
Endpoint:
- `GET /cable` for websocket upgrade
Channel:
- `TournamentTeamActionListsChannel`
Subscription identifier example:
```json
{
"channel": "TournamentTeamActionListsChannel",
"tournament_id": 123
}
```
Behavior:
- subscription immediately receives current full team-action-list snapshot for that tournament
- later list/item changes broadcast updated full snapshot again
- same broadcast path is used for:
- direct source updates
- follower sync imports
- both write 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`
Payload shape:
```json
{
"type": "team_action_lists.updated",
"tournament_id": 123,
"team_action_lists": [
{
"id": 9,
"name": "Playoff token collection",
"key": "group_stage_survivor_playoff_tokens",
"action_name": "Collect playoff token",
"source": "group_stage_survivors",
"team_action_items": [
{
"id": 41,
"completed": true,
"completed_at": "2026-04-23T09:15:00Z",
"team": {
"id": 55,
"name": "Team A"
}
}
]
}
]
}
```
Frontend guidance:
- use websocket payload as replacement snapshot for team-action-list state in that tournament
- do not merge by toggling local state blindly
- keep writes item-scoped through HTTP
- use websocket only for read/live propagation
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
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:
- stable list `key`
- user-facing `name`
- user-facing `action_name`
New list types should automatically fit the same frontend rendering model:
- list metadata
- list items
- explicit `completed` state
- idempotent set-state requests

View File

@ -0,0 +1,147 @@
# frozen_string_literal: true
require 'json'
require 'socket'
require 'openssl'
require 'uri'
require 'websocket/driver'
module TurniereE2E
class ActionCableClient
ACTION_CABLE_PROTOCOLS = %w[actioncable-v1-json actioncable-unsupported].freeze
def initialize(base_url:)
@base_url = base_url.sub(%r{/\z}, '')
@messages = Queue.new
@closed = false
end
def subscribe_team_action_lists!(tournament_id:)
connect!
identifier = {
channel: 'TournamentTeamActionListsChannel',
tournament_id: tournament_id
}.to_json
driver.text(
JSON.dump(
command: 'subscribe',
identifier: identifier
)
)
handshake_message = wait_for! do |message|
message['type'] == 'confirm_subscription' ||
(message['identifier'] == identifier && message['message'].is_a?(Hash))
end
@messages << handshake_message if team_action_lists_payload_message?(handshake_message)
end
def wait_for_payload!(timeout: 20)
wait_for!(timeout: timeout) do |message|
payload = message['message']
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
end.fetch('message')
end
def close
return if @closed
@closed = true
@driver&.close
@socket&.close
rescue IOError, SystemCallError
nil
end
def url
websocket_url
end
def write(data)
socket.write(data)
end
private
attr_reader :base_url, :socket, :driver
def connect!
return unless @driver.nil?
@socket = build_socket
@driver = WebSocket::Driver.client(self, protocols: ACTION_CABLE_PROTOCOLS)
@driver.set_header('Origin', http_origin)
@driver.on(:message) { |event| @messages << JSON.parse(event.data) }
@driver.start
pump_until_open!
end
def build_socket
uri = URI.parse(websocket_url)
tcp_socket = TCPSocket.new(uri.host, uri.port)
return tcp_socket unless uri.scheme == 'wss'
ssl_context = OpenSSL::SSL::SSLContext.new
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
ssl_socket.hostname = uri.host if ssl_socket.respond_to?(:hostname=)
ssl_socket.connect
ssl_socket
end
def websocket_url
uri = URI.parse(base_url)
uri.scheme = uri.scheme == 'https' ? 'wss' : 'ws'
uri.path = '/cable'
uri.query = nil
uri.to_s
end
def http_origin
uri = URI.parse(base_url)
"#{uri.scheme}://#{uri.host}:#{uri.port}"
end
def pump_until_open!(timeout: 10)
deadline = Time.now + timeout
while Time.now < deadline
read_from_socket(timeout: 0.5)
return if driver.ready_state == 1
end
raise "websocket did not open within #{timeout}s"
end
def wait_for!(timeout: 20)
deadline = Time.now + timeout
loop do
until @messages.empty?
message = @messages.pop(true)
return message if yield(message)
end
raise "websocket message not received within #{timeout}s" if Time.now >= deadline
read_from_socket(timeout: 0.5)
rescue ThreadError
nil
end
end
def read_from_socket(timeout:)
readable, = IO.select([socket], nil, nil, timeout)
return if readable.nil?
chunk = socket.readpartial(4096)
driver.parse(chunk)
rescue EOFError
nil
end
def team_action_lists_payload_message?(message)
payload = message['message']
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
end
end
end

View File

@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do
expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams) expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams)
.to match_array(GroupStageService.get_advancing_teams(running_group_stage)) .to match_array(GroupStageService.get_advancing_teams(running_group_stage))
end end
it 'creates playoff token collection list for advancing teams' 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])
end
end end
context 'as another user' do context 'as another user' do

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionItemsController, type: :controller do
let(:team_action_item) { create(:team_action_item) }
describe 'PATCH #update' do
context 'as owner' do
before do
apply_authentication_headers_for team_action_item.owner
end
it 'ticks item off' do
patch :update, params: { id: team_action_item.to_param, completed: true }
expect(response).to be_successful
expect(team_action_item.reload.completed).to eq(true)
expect(team_action_item.completed_at).not_to be_nil
end
it 'enqueues tournament sync' do
allow(TournamentSyncEnqueue).to receive(:call)
patch :update, params: { id: team_action_item.to_param, completed: true }
expect(TournamentSyncEnqueue).to have_received(:call).with(team_action_item.tournament)
end
it 'can untick item' do
team_action_item.update!(completed: true, completed_at: Time.zone.parse('2026-04-22T09:00:00Z'))
patch :update, params: { id: team_action_item.to_param, completed: false }
expect(response).to be_successful
expect(team_action_item.reload.completed).to eq(false)
expect(team_action_item.completed_at).to be_nil
end
end
context 'as another user' do
before do
apply_authentication_headers_for create(:user)
end
it 'returns forbidden' do
patch :update, params: { id: team_action_item.to_param, completed: true }
expect(response).to have_http_status(:forbidden)
end
end
end
end

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionListsController, type: :controller do
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
context 'as owner' do
before do
apply_authentication_headers_for tournament.owner
end
it 'updates item by list key and team id' do
patch :update_item, params: {
tournament_id: tournament.to_param,
key: list.key,
team_id: team.to_param,
completed: true
}
expect(response).to be_successful
expect(team_action_item.reload.completed).to eq(true)
end
it 'is idempotent when same state sent repeatedly' do
2.times do
patch :update_item, params: {
tournament_id: tournament.to_param,
key: list.key,
team_id: team.to_param,
completed: true
}
expect(response).to be_successful
end
expect(team_action_item.reload.completed).to eq(true)
end
it 'enqueues tournament sync' do
allow(TournamentSyncEnqueue).to receive(:call)
patch :update_item, params: {
tournament_id: tournament.to_param,
key: list.key,
team_id: team.to_param,
completed: true
}
expect(TournamentSyncEnqueue).to have_received(:call).with(tournament)
end
end
context 'as another user' do
before do
apply_authentication_headers_for create(:user)
end
it 'returns forbidden' do
patch :update_item, params: {
tournament_id: tournament.to_param,
key: list.key,
team_id: team.to_param,
completed: true
}
expect(response).to have_http_status(:forbidden)
end
end
end
end

View File

@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[ expected_keys = %i[
id name code public description playoff_teams_amount id name code public description playoff_teams_amount
instant_finalists_amount intermediate_round_participants_amount instant_finalists_amount intermediate_round_participants_amount
timer_end owner_username stages teams timer_end owner_username stages teams team_action_lists
] ]
expect(json.keys).to match_array(expected_keys) expect(json.keys).to match_array(expected_keys)
expect(json[:name]).to eq(@tournament.name) expect(json[:name]).to eq(@tournament.name)
@ -204,6 +204,19 @@ RSpec.describe TournamentsController, type: :controller do
advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) } advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) }
expect(advancing_teams).to match_array(expected_advancing_teams) expect(advancing_teams).to match_array(expected_advancing_teams)
end end
it 'includes team action lists and item status for all viewers' do
list = create(:team_action_list, tournament: @group_stage_tournament)
create(:team_action_item, team_action_list: list, team: @group_stage_tournament.teams.first, completed: true)
allow(GroupStageService).to receive(:get_advancing_teams).and_return([])
get :show, params: { id: @group_stage_tournament.to_param }
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[:team_action_items].first[:completed]).to eq(true)
end
end end
end end

View File

@ -141,6 +141,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) 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(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
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) })
follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first 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) follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team)
@ -148,6 +152,27 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(locked_team_update[:status]).to eq(423) expect(locked_team_update[:status]).to eq(423)
expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode')
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)}",
body: { completed: true }
)
expect(source_token_update[:status]).to eq(200)
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_item_completed?(source, team_name: source_token_item.dig(:team, :name))).to eq(true)
expect(playoff_token_item_completed?(follower, team_name: source_token_item.dig(:team, :name))).to eq(true)
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)}",
body: { completed: false }
)
expect(locked_token_update[:status]).to eq(423)
expect(locked_token_update.dig(:json, :error)).to eq('Tournament is in read only follower mode')
source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4)) source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4))
follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5)) follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5))
exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise)
@ -451,6 +476,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
timer_end: tournament[:timer_end], timer_end: tournament[:timer_end],
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) }, teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
{
key: list.fetch(:key),
name: list.fetch(:name),
action_name: list.fetch(:action_name),
source: list.fetch(:source),
team_action_items: list.fetch(:team_action_items).map do |item|
{
team_name: item.dig(:team, :name),
completed: item.fetch(:completed),
completed_at: item[:completed_at]
}
end.sort_by { |item| item.fetch(:team_name) }
}
end,
stages: tournament.fetch(:stages).sort_by { |stage| stage.fetch(:level) }.map do |stage| stages: tournament.fetch(:stages).sort_by { |stage| stage.fetch(:level) }.map do |stage|
{ {
level: stage.fetch(:level), level: stage.fetch(:level),
@ -509,4 +549,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
@unique_suffix_counter += 1 @unique_suffix_counter += 1
"#{SecureRandom.hex(3)}-#{@unique_suffix_counter}" "#{SecureRandom.hex(3)}-#{@unique_suffix_counter}"
end end
def playoff_token_list(tournament)
tournament.fetch(:team_action_lists, []).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens'
end
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
end
end
def playoff_token_item_completed?(tournament, team_name:)
find_team_action_item(tournament, team_name: team_name).fetch(:completed)
end
end end

View File

@ -0,0 +1,173 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
RSpec.describe 'Tournament follower sync websocket HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_EMAIL
TURNIERE_E2E_PASSWORD
TURNIERE_SOURCE_E2E_BASE_URL
TURNIERE_FOLLOWER_E2E_BASE_URL
].freeze
before do
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
end
let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') }
let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
let(:source_client) { login_client(base_url: source_base_url) }
let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) }
it 'broadcasts follower websocket updates after source sync import' do
source = create_group_stage_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Sync Source')
follower = create_follower_tournament(name_prefix: 'Ws Sync Follower')
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id))
follower = wait_for_list_presence!(follower.fetch(:id))
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
cable_client.subscribe_team_action_lists!(tournament_id: follower.fetch(:id))
cable_client.wait_for_payload!
source_after_playoffs = fetch_tournament(base_url: source_base_url, tournament_id: source.fetch(:id))
token_item = find_playoff_token_list!(source_after_playoffs).fetch(:team_action_items).first
update = source_client.patch("/team_action_items/#{token_item.fetch(:id)}", body: { completed: true })
expect(update[:status]).to eq(200)
follower_payload = wait_for_team_action_list_state!(
cable_client,
team_name: token_item.dig(:team, :name),
completed: true
)
expect(team_action_item_completed?(follower_payload, team_name: token_item.dig(:team, :name))).to eq(true)
ensure
cable_client&.close
end
def login_client(base_url:)
client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
client
end
def create_group_stage_tournament(client:, base_url:, name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower websocket source tournament',
public: false,
group_stage: true,
playoff_teams_amount: 4,
teams: 2.times.flat_map do |group_index|
4.times.map do |seed_index|
{
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index
}
end
end
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url: base_url, tournament_id: response.dig(:json, :id))
end
def create_follower_tournament(name_prefix:)
client = login_client(base_url: follower_base_url)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Follower websocket target tournament',
public: true,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(response[:status]).to eq(201)
fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id))
end
def fetch_tournament(base_url:, tournament_id:)
client = TurniereE2E::ApiClient.new(base_url: base_url)
response = client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200)
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 }
group_stage.fetch(:groups).each do |group|
group.fetch(:matches).each_with_index do |match, index|
play_match!(match.fetch(:id), first_score: 10 + index, second_score: index)
end
end
finish_stage = source_client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(finish_stage[:status]).to eq(200)
end
def play_match!(match_id, first_score:, second_score:)
match_response = TurniereE2E::ApiClient.new(base_url: source_base_url).get("/matches/#{match_id}")
expect(match_response[:status]).to eq(200)
start_response = source_client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
[first_score, second_score].each_with_index do |points, index|
score_update = source_client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
expect(score_update[:status]).to eq(200)
end
finish_response = source_client.patch("/matches/#{match_id}", body: { state: 'finished' })
expect(finish_response[:status]).to eq(200)
end
def wait_for_list_presence!(tournament_id, timeout: 20)
deadline = Time.now + timeout
loop do
follower = fetch_tournament(base_url: follower_base_url, tournament_id: tournament_id)
return follower if follower.fetch(:team_action_lists, []).any?
raise "follower list not present within #{timeout}s" if Time.now >= deadline
sleep 0.5
end
end
def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if team_action_item_completed?(payload, team_name: team_name) == completed
raise "follower websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens'
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' }
item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name }
item.fetch('completed')
end
end

View File

@ -54,6 +54,42 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
end end
it 'syncs team action list changes made through item id route and business key route' do
follower = create_follower_tournament(name_prefix: 'Sync Action Follower')
leader = create_group_stage_tournament(
name_prefix: 'Sync Action Leader',
sync_target_url: "#{base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
sync_auth_token: sync_token
)
finish_group_stage_and_create_playoffs!(leader.fetch(:id))
leader_after_playoffs = fetch_tournament(leader.fetch(:id))
token_list = find_playoff_token_list!(leader_after_playoffs)
first_item = token_list.fetch(:team_action_items).first
second_item = token_list.fetch(:team_action_items)[1]
update_by_item_id = client.patch("/team_action_items/#{first_item.fetch(:id)}", body: { completed: true })
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)}",
body: { completed: true }
)
expect(update_by_business_key[:status]).to eq(200)
test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync")
expect(test_sync[:status]).to eq(200)
leader_after_update = fetch_tournament(leader.fetch(:id))
follower_after_update = fetch_tournament(follower.fetch(:id))
expect(playoff_token_item_completed?(leader_after_update, team_name: first_item.dig(:team, :name))).to eq(true)
expect(playoff_token_item_completed?(leader_after_update, team_name: second_item.dig(:team, :name))).to eq(true)
expect(playoff_token_item_completed?(follower_after_update, team_name: first_item.dig(:team, :name))).to eq(true)
expect(playoff_token_item_completed?(follower_after_update, team_name: second_item.dig(:team, :name))).to eq(true)
end
def login_client def login_client
api_client = TurniereE2E::ApiClient.new(base_url: base_url) api_client = TurniereE2E::ApiClient.new(base_url: base_url)
response = api_client.login!(email: owner_email, password: owner_password) response = api_client.login!(email: owner_email, password: owner_password)
@ -103,4 +139,47 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(response[:status]).to eq(200) expect(response[:status]).to eq(200)
response.fetch(:json) response.fetch(:json)
end end
def finish_group_stage_and_create_playoffs!(tournament_id)
tournament = fetch_tournament(tournament_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
group_stage.fetch(:groups).each do |group|
group.fetch(:matches).each_with_index do |match, index|
play_match!(match.fetch(:id), first_score: 10 + index, second_score: index)
end
end
finish_stage = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(finish_stage[:status]).to eq(200)
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)
start_response = client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
[first_score, second_score].each_with_index do |points, index|
score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
expect(score_update[:status]).to eq(200)
end
finish_response = client.patch("/matches/#{match_id}", body: { state: 'finished' })
expect(finish_response[:status]).to eq(200)
end
def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens'
end || raise('playoff token list missing')
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 }
item.fetch(:completed)
end
end end

View File

@ -0,0 +1,154 @@
# frozen_string_literal: true
require 'spec_helper'
require 'securerandom'
require_relative '../../../e2e/lib/api_client'
require_relative '../../../e2e/lib/action_cable_client'
RSpec.describe 'Tournament team action lists websocket HTTP E2E' do
required_env_vars = %w[
TURNIERE_E2E_BASE_URL
TURNIERE_E2E_EMAIL
TURNIERE_E2E_PASSWORD
].freeze
before do
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
end
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL') }
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
let(:client) { login_client }
let(:anonymous_client) { TurniereE2E::ApiClient.new(base_url: base_url) }
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))
tournament = fetch_tournament(tournament.fetch(:id))
token_list = find_playoff_token_list!(tournament)
cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url)
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')
first_item = token_list.fetch(:team_action_items).first
second_item = token_list.fetch(:team_action_items)[1]
update_by_item_id = client.patch("/team_action_items/#{first_item.fetch(:id)}", body: { completed: true })
expect(update_by_item_id[:status]).to eq(200)
first_update = wait_for_team_action_list_state!(
cable_client,
team_name: first_item.dig(:team, :name),
completed: true
)
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)}",
body: { completed: true }
)
expect(update_by_business_key[:status]).to eq(200)
second_update = wait_for_team_action_list_state!(
cable_client,
team_name: second_item.dig(:team, :name),
completed: true
)
expect(team_action_item_completed?(second_update, team_name: first_item.dig(:team, :name))).to eq(true)
expect(team_action_item_completed?(second_update, team_name: second_item.dig(:team, :name))).to eq(true)
ensure
cable_client&.close
end
def login_client
api_client = TurniereE2E::ApiClient.new(base_url: base_url)
response = api_client.login!(email: owner_email, password: owner_password)
expect(response[:status]).to eq(200)
api_client
end
def create_group_stage_tournament(name_prefix:)
response = client.post('/tournaments', body: {
name: "#{name_prefix} #{SecureRandom.hex(3)}",
description: 'Websocket action list tournament',
public: false,
group_stage: true,
playoff_teams_amount: 4,
teams: 2.times.flat_map do |group_index|
4.times.map do |seed_index|
{
name: "#{name_prefix.tr(' ', '')}-G#{group_index + 1}-S#{seed_index + 1}",
group: group_index
}
end
end
})
expect(response[:status]).to eq(201)
fetch_tournament(response.dig(:json, :id))
end
def fetch_tournament(tournament_id)
response = anonymous_client.get("/tournaments/#{tournament_id}")
expect(response[:status]).to eq(200)
response.fetch(:json)
end
def finish_group_stage_and_create_playoffs!(tournament_id)
tournament = fetch_tournament(tournament_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
group_stage.fetch(:groups).each do |group|
group.fetch(:matches).each_with_index do |match, index|
play_match!(match.fetch(:id), first_score: 10 + index, second_score: index)
end
end
finish_stage = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' })
expect(finish_stage[:status]).to eq(200)
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)
start_response = client.patch("/matches/#{match_id}", body: { state: 'in_progress' })
expect(start_response[:status]).to eq(200)
ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) }
[first_score, second_score].each_with_index do |points, index|
score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points })
expect(score_update[:status]).to eq(200)
end
finish_response = client.patch("/matches/#{match_id}", body: { state: 'finished' })
expect(finish_response[:status]).to eq(200)
end
def wait_for_team_action_list_state!(cable_client, team_name:, completed:, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if team_action_item_completed?(payload, team_name: team_name) == completed
raise "team action websocket payload not received within #{timeout}s" if Time.now >= deadline
end
end
def find_playoff_token_list!(tournament)
tournament.fetch(:team_action_lists).find do |list|
list.fetch(:key) == 'group_stage_survivor_playoff_tokens'
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' }
item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name }
item.fetch('completed')
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :team_action_item do
team_action_list
team { create(:team, tournament: team_action_list.tournament) }
completed { false }
completed_at { nil }
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :team_action_list do
tournament
name { 'Playoff token collection' }
key { 'group_stage_survivor_playoff_tokens' }
action_name { 'Collect playoff token' }
source { 'group_stage_survivors' }
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionItemsController, type: :routing do
describe 'routing' do
it 'routes to #update via PATCH' do
expect(patch: '/team_action_items/1').to route_to('team_action_items#update', id: '1')
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TeamActionListsController, type: :routing do
describe 'routing' do
it 'routes to #update_item via PATCH' do
expect(patch: '/tournaments/1/team_action_lists/group_stage_survivor_playoff_tokens/teams/2')
.to route_to(
'team_action_lists#update_item',
tournament_id: '1',
key: 'group_stage_survivor_playoff_tokens',
team_id: '2'
)
end
end
end

View File

@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter do
it 'replaces follower tournament graph using source ids as stable mapping keys' do it 'replaces follower tournament graph using source ids as stable mapping keys' do
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr') source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr')
list = create(:team_action_list, tournament: source)
create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true)
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
snapshot = TournamentSnapshotBuilder.build(source) snapshot = TournamentSnapshotBuilder.build(source)
@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.name).to eq(source.name) expect(follower.name).to eq(source.name)
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count)
expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count)
expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count)
expect(follower.stages.count).to eq(source.stages.count) expect(follower.stages.count).to eq(source.stages.count)
expect(follower.matches.count).to eq(source.matches.count) expect(follower.matches.count).to eq(source.matches.count)
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
timer_end: tournament.timer_end&.iso8601, timer_end: tournament.timer_end&.iso8601,
teams: tournament.teams.order(:name).map { |team| team.name }, teams: tournament.teams.order(:name).map { |team| team.name },
team_action_lists: tournament.team_action_lists.order(:key).map do |list|
{
key: list.key,
name: list.name,
action_name: list.action_name,
source: list.source,
items: list.team_action_items.includes(:team).sort_by { |item| item.team.name }.map do |item|
{
team_name: item.team.name,
completed: item.completed,
completed_at: item.completed_at&.iso8601
}
end
}
end,
beamers: tournament.beamers.order(:name).map do |beamer| beamers: tournament.beamers.order(:name).map do |beamer|
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
end, end,

View File

@ -414,7 +414,8 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
) )
if extra_env: if extra_env:
env.update(extra_env) env.update(extra_env)
command = _rspec_command(spec_path) spec_paths = spec_path if isinstance(spec_path, (list, tuple)) else [spec_path]
command = _rspec_command(*spec_paths)
print(_format_command(command), flush=True) print(_format_command(command), flush=True)
result = _stream_subprocess(command, env=env, check=False) result = _stream_subprocess(command, env=env, check=False)
@ -1692,7 +1693,10 @@ def blackbox_follow_service(
alt_email, alt_email,
alt_password, alt_password,
alt_username, alt_username,
spec_path="spec/e2e/http/tournament_follow_sync_spec.rb", spec_path=[
"spec/e2e/http/tournament_follow_sync_spec.rb",
"spec/e2e/http/tournament_follow_sync_websocket_spec.rb",
],
extra_env={ extra_env={
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,
"TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url, "TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url,