feat: add websocket team action updates

This commit is contained in:
Daniel Schädler 2026-04-23 10:19:47 +02:00
parent f81a8fb83e
commit 883638f4fd
16 changed files with 649 additions and 3 deletions

View File

@ -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 - 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`.
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 ## Practical Expectation
If you touch test, verification, boot, Docker, or scenario setup workflows, If you touch test, verification, boot, Docker, or scenario setup workflows,

View File

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

View File

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

View File

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

View File

@ -9,10 +9,20 @@ class TeamActionItem < ApplicationRecord
delegate :tournament, :owner, to: :team_action_list delegate :tournament, :owner, to: :team_action_list
before_validation :normalize_completed_at before_validation :normalize_completed_at
before_destroy :store_tournament_id_for_broadcast
after_commit :broadcast_state_change, on: %i[create update destroy]
private private
def normalize_completed_at def normalize_completed_at
self.completed_at = completed? ? (completed_at || Time.current) : nil self.completed_at = completed? ? (completed_at || Time.current) : nil
end 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 end

View File

@ -9,6 +9,9 @@ class TeamActionList < ApplicationRecord
delegate :owner, to: :tournament 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) def sync_teams!(teams)
desired_team_ids = teams.map(&:id).uniq desired_team_ids = teams.map(&:id).uniq
@ -20,4 +23,14 @@ class TeamActionList < ApplicationRecord
end end
end 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 end

View File

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

View File

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

View File

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

8
config/cable.yml Normal file
View File

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

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.routes.draw do Rails.application.routes.draw do
mount ActionCable.server => '/cable'
mount_devise_token_auth_for 'User', at: 'users', controllers: { mount_devise_token_auth_for 'User', at: 'users', controllers: {
registrations: 'overrides/registrations', registrations: 'overrides/registrations',
sessions: 'overrides/sessions' sessions: 'overrides/sessions'

View File

@ -160,6 +160,71 @@ That means:
- follower tournament receives updated list state - follower tournament receives updated list state
- follower users can view but not mutate item state while read-only - 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 ## Future Extension Guidance
When new lists are added, frontend should not hardcode backend internals beyond: When new lists are added, frontend should not hardcode backend internals beyond:

View File

@ -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

View File

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

View File

@ -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

View File

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