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:
commit
e41ef870f9
37
AGENTS.md
37
AGENTS.md
|
|
@ -52,6 +52,22 @@ Examples:
|
|||
- `inv docker-test-shard --node-index=3 --node-total=8`
|
||||
- `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:
|
||||
|
||||
- `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
|
||||
- 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
|
||||
|
||||
If you touch test, verification, boot, Docker, or scenario setup workflows,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -65,9 +65,12 @@ class StagesController < ApplicationController
|
|||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
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
|
||||
|
||||
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -5,6 +5,7 @@ class Team < ApplicationRecord
|
|||
has_many :group_scores, dependent: :destroy
|
||||
has_many :match_scores, dependent: :destroy
|
||||
has_many :bets, dependent: :destroy
|
||||
has_many :team_action_items, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -7,6 +7,7 @@ class Tournament < ApplicationRecord
|
|||
has_many :teams, dependent: :destroy
|
||||
has_many :stages, dependent: :destroy
|
||||
has_many :beamers, dependent: :destroy
|
||||
has_many :team_action_lists, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :code, presence: true, uniqueness: true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionListSerializer < ApplicationSerializer
|
||||
attributes :name, :key, :action_name, :source
|
||||
|
||||
has_many :team_action_items
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
|
||||
|
||||
has_many :stages
|
||||
has_many :team_action_lists
|
||||
|
||||
attribute :timer_end do
|
||||
object.timer_end&.iso8601
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -9,6 +9,8 @@ class TournamentSnapshotBuilder
|
|||
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).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
|
||||
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)
|
||||
new(tournament).build
|
||||
|
|
@ -29,6 +31,8 @@ class TournamentSnapshotBuilder
|
|||
payload[:matches] = matches_payload
|
||||
payload[:match_scores] = match_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
|
||||
end
|
||||
|
||||
|
|
@ -78,6 +82,20 @@ class TournamentSnapshotBuilder
|
|||
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
|
||||
(
|
||||
tournament.teams.to_a +
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class TournamentSnapshotImporter
|
|||
existing_matches = tournament.matches
|
||||
existing_match_scores = existing_matches.flat_map(&:match_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!
|
||||
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)
|
||||
sync_match_scores!(matches_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_group_scores, snapshot.fetch(:group_scores, []))
|
||||
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
|
||||
|
|
@ -162,6 +168,33 @@ class TournamentSnapshotImporter
|
|||
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:)
|
||||
existing_by_source_id = existing_records.index_by(&:sync_source_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ class TournamentSyncSchema
|
|||
GroupScore => {
|
||||
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
|
||||
}.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
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ require 'active_job/railtie'
|
|||
require 'active_record/railtie'
|
||||
require 'active_storage/engine'
|
||||
require 'action_controller/railtie'
|
||||
require 'action_cable/engine'
|
||||
require 'action_mailer/railtie'
|
||||
require 'action_view/railtie'
|
||||
# require "action_cable/engine"
|
||||
# require "sprockets/railtie"
|
||||
require 'rails/test_unit/railtie'
|
||||
|
||||
|
|
@ -34,6 +34,8 @@ module TurniereBackend
|
|||
# Middleware like session, flash, cookies can be added back manually.
|
||||
# Skip views, helpers and assets when generating a new resource.
|
||||
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
|
||||
allow do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
development:
|
||||
adapter: async
|
||||
|
||||
test:
|
||||
adapter: async
|
||||
|
||||
production:
|
||||
adapter: async
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.routes.draw do
|
||||
mount ActionCable.server => '/cable'
|
||||
|
||||
mount_devise_token_auth_for 'User', at: 'users', controllers: {
|
||||
registrations: 'overrides/registrations',
|
||||
sessions: 'overrides/sessions'
|
||||
|
|
@ -17,7 +19,9 @@ Rails.application.routes.draw do
|
|||
end
|
||||
resources :stages, only: %i[show update]
|
||||
resources :teams, only: %i[show update]
|
||||
resources :team_action_items, only: %i[update]
|
||||
resources :tournaments do
|
||||
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
|
||||
resources :statistics, only: %i[index]
|
||||
resources :matches, only: %i[index]
|
||||
resources :beamers, only: %i[index show create update destroy] do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do
|
|||
expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams)
|
||||
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
|
||||
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
|
||||
|
||||
context 'as another user' do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expected_keys = %i[
|
||||
id name code public description playoff_teams_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[: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]) }
|
||||
expect(advancing_teams).to match_array(expected_advancing_teams)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ 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(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_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.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))
|
||||
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)
|
||||
|
|
@ -451,6 +476,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
|
||||
timer_end: tournament[:timer_end],
|
||||
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|
|
||||
{
|
||||
level: stage.fetch(:level),
|
||||
|
|
@ -509,4 +549,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
|
|||
@unique_suffix_counter += 1
|
||||
"#{SecureRandom.hex(3)}-#{@unique_suffix_counter}"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -54,6 +54,42 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
|||
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
|
||||
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
|
||||
api_client = TurniereE2E::ApiClient.new(base_url: base_url)
|
||||
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)
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter 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.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')
|
||||
|
||||
snapshot = TournamentSnapshotBuilder.build(source)
|
||||
|
|
@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do
|
|||
expect(follower.name).to eq(source.name)
|
||||
expect(follower.teams.count).to eq(snapshot.fetch(:teams).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.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) })
|
||||
|
|
@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do
|
|||
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
||||
timer_end: tournament.timer_end&.iso8601,
|
||||
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|
|
||||
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
|
||||
end,
|
||||
|
|
|
|||
8
tasks.py
8
tasks.py
|
|
@ -414,7 +414,8 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
|
|||
)
|
||||
if 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)
|
||||
result = _stream_subprocess(command, env=env, check=False)
|
||||
|
||||
|
|
@ -1692,7 +1693,10 @@ def blackbox_follow_service(
|
|||
alt_email,
|
||||
alt_password,
|
||||
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={
|
||||
"TURNIERE_SOURCE_E2E_BASE_URL": source_base_url,
|
||||
"TURNIERE_FOLLOWER_E2E_BASE_URL": follower_base_url,
|
||||
|
|
|
|||
Loading…
Reference in New Issue