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-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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
end
|
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 :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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 :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
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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_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 +
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
development:
|
||||||
|
adapter: async
|
||||||
|
|
||||||
|
test:
|
||||||
|
adapter: async
|
||||||
|
|
||||||
|
production:
|
||||||
|
adapter: async
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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[
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
.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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,
|
||||||
|
|
|
||||||
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:
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue