feat: add websocket team action updates
This commit is contained in:
parent
f81a8fb83e
commit
883638f4fd
17
AGENTS.md
17
AGENTS.md
|
|
@ -163,6 +163,23 @@ 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`.
|
||||
|
||||
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
|
||||
- broadcast full current list snapshot for that tournament
|
||||
- keep write APIs idempotent and item-scoped
|
||||
- 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
|
||||
|
|
@ -9,10 +9,20 @@ class TeamActionItem < ApplicationRecord
|
|||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ class TeamActionList < ApplicationRecord
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -20,4 +23,14 @@ class TeamActionList < ApplicationRecord
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -160,6 +160,71 @@ That means:
|
|||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
## Future Extension Guidance
|
||||
|
||||
When new lists are added, frontend should not hardcode backend internals beyond:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
# 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
|
||||
)
|
||||
)
|
||||
|
||||
wait_for! do |message|
|
||||
message['type'] == 'confirm_subscription' ||
|
||||
(message['identifier'] == identifier && message['message'].is_a?(Hash))
|
||||
end
|
||||
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
|
||||
|
||||
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 url
|
||||
websocket_url
|
||||
end
|
||||
|
||||
def write(data)
|
||||
socket.write(data)
|
||||
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
|
||||
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
|
||||
|
|
@ -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).second
|
||||
|
||||
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
|
||||
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