From 164b7df625d07480753e93d01478603fefd452e7 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 16:41:29 +0200 Subject: [PATCH 01/12] docs: note isolated local test runs --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dfd7cf5..469f657 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,22 @@ Examples: - `inv docker-test-shard --node-index=3 --node-total=8` - `inv docker-test-http-e2e` +## Concurrent Local Testing + +If other agents or local runs may already be using shared test resources, do not +reuse `db/test.sqlite3` or fixed ports for ad-hoc verification. + +Prefer isolated per-run resources instead: + +- use a unique temporary sqlite database via `DATABASE_URL=sqlite3:/tmp/...` +- choose open ports automatically or derive unique ports per run +- keep normal repo tasks as default, but use isolated overrides when avoiding collisions is important + +Practical expectation: + +- for targeted/spec-specific verification where `inv` does not provide isolation knobs, it is acceptable to run the underlying command with isolated env vars +- if isolated local verification becomes recurring, add or extend an `inv` task for it + For local E2E-style startup with a usable login, prefer: - `inv start-local --bootstrap-user` From 7d65e4df826668575a513fe2a6aac0a2cb392756 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 16:51:17 +0200 Subject: [PATCH 02/12] feat: track team action list completion --- app/controllers/stages_controller.rb | 7 ++- .../team_action_items_controller.rb | 27 +++++++++++ app/models/team.rb | 1 + app/models/team_action_item.rb | 18 ++++++++ app/models/team_action_list.rb | 23 ++++++++++ app/models/tournament.rb | 1 + .../team_action_item_serializer.rb | 16 +++++++ .../team_action_list_serializer.rb | 7 +++ app/serializers/tournament_serializer.rb | 1 + app/services/team_action_list_service.rb | 34 ++++++++++++++ app/services/tournament_snapshot_builder.rb | 18 ++++++++ app/services/tournament_snapshot_importer.rb | 33 ++++++++++++++ app/services/tournament_sync_schema.rb | 8 ++++ config/routes.rb | 1 + ...20260422100000_create_team_action_lists.rb | 30 +++++++++++++ spec/controllers/stages_controller_spec.rb | 10 +++++ .../team_action_items_controller_spec.rb | 45 +++++++++++++++++++ .../tournaments_controller_spec.rb | 15 ++++++- spec/factories/team_action_items.rb | 10 +++++ spec/factories/team_action_lists.rb | 11 +++++ .../routing/team_action_items_routing_spec.rb | 11 +++++ .../tournament_snapshot_importer_spec.rb | 19 ++++++++ 22 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 app/controllers/team_action_items_controller.rb create mode 100644 app/models/team_action_item.rb create mode 100644 app/models/team_action_list.rb create mode 100644 app/serializers/team_action_item_serializer.rb create mode 100644 app/serializers/team_action_list_serializer.rb create mode 100644 app/services/team_action_list_service.rb create mode 100644 db/migrate/20260422100000_create_team_action_lists.rb create mode 100644 spec/controllers/team_action_items_controller_spec.rb create mode 100644 spec/factories/team_action_items.rb create mode 100644 spec/factories/team_action_lists.rb create mode 100644 spec/routing/team_action_items_routing_spec.rb diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index fcdac4d..5cada55 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -65,8 +65,11 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end - return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? + return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: GroupStageService.get_advancing_teams(@stage)).success? + + TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament) + return render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity raise ActiveRecord::Rollback diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb new file mode 100644 index 0000000..8011ef6 --- /dev/null +++ b/app/controllers/team_action_items_controller.rb @@ -0,0 +1,27 @@ +# 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 @team_action_item.update(team_action_item_params) + 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 diff --git a/app/models/team.rb b/app/models/team.rb index d81563e..4ad8508 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,6 +5,7 @@ class Team < ApplicationRecord has_many :group_scores, dependent: :destroy has_many :match_scores, dependent: :destroy has_many :bets, dependent: :destroy + has_many :team_action_items, dependent: :destroy validates :name, presence: true diff --git a/app/models/team_action_item.rb b/app/models/team_action_item.rb new file mode 100644 index 0000000..9724f59 --- /dev/null +++ b/app/models/team_action_item.rb @@ -0,0 +1,18 @@ +# 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 + + private + + def normalize_completed_at + self.completed_at = completed? ? (completed_at || Time.current) : nil + end +end diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb new file mode 100644 index 0000000..cd68cc9 --- /dev/null +++ b/app/models/team_action_list.rb @@ -0,0 +1,23 @@ +# 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 + + 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 +end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 4b284d8..729fdab 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -7,6 +7,7 @@ class Tournament < ApplicationRecord has_many :teams, dependent: :destroy has_many :stages, dependent: :destroy has_many :beamers, dependent: :destroy + has_many :team_action_lists, dependent: :destroy validates :name, presence: true validates :code, presence: true, uniqueness: true diff --git a/app/serializers/team_action_item_serializer.rb b/app/serializers/team_action_item_serializer.rb new file mode 100644 index 0000000..f7a617e --- /dev/null +++ b/app/serializers/team_action_item_serializer.rb @@ -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 diff --git a/app/serializers/team_action_list_serializer.rb b/app/serializers/team_action_list_serializer.rb new file mode 100644 index 0000000..5695a5c --- /dev/null +++ b/app/serializers/team_action_list_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TeamActionListSerializer < ApplicationSerializer + attributes :name, :key, :action_name, :source + + has_many :team_action_items +end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index a01caf1..9389365 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer # NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against has_many :stages + has_many :team_action_lists attribute :timer_end do object.timer_end&.iso8601 diff --git a/app/services/team_action_list_service.rb b/app/services/team_action_list_service.rb new file mode 100644 index 0000000..e613ee3 --- /dev/null +++ b/app/services/team_action_list_service.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index a1b4ee4..48b61d4 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -9,6 +9,8 @@ class TournamentSnapshotBuilder MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze + TEAM_ACTION_LIST_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionList).map(&:to_sym).freeze + TEAM_ACTION_ITEM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionItem).map(&:to_sym).freeze def self.build(tournament) new(tournament).build @@ -29,6 +31,8 @@ class TournamentSnapshotBuilder payload[:matches] = matches_payload payload[:match_scores] = match_scores_payload payload[:group_scores] = group_scores_payload + payload[:team_action_lists] = team_action_lists_payload + payload[:team_action_items] = team_action_items_payload payload end @@ -78,6 +82,20 @@ class TournamentSnapshotBuilder end end + def team_action_lists_payload + tournament.team_action_lists.order(:id).map do |team_action_list| + team_action_list.attributes.symbolize_keys.slice(*TEAM_ACTION_LIST_ATTRIBUTES) + end + end + + def team_action_items_payload + tournament.team_action_lists.flat_map(&:team_action_items).sort_by(&:id).map do |team_action_item| + attrs = team_action_item.attributes.symbolize_keys.slice(*TEAM_ACTION_ITEM_ATTRIBUTES) + attrs[:completed_at] = team_action_item.completed_at&.iso8601 + attrs + end + end + def teams_for_snapshot ( tournament.teams.to_a + diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 72630d7..0a1657f 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -32,6 +32,8 @@ class TournamentSnapshotImporter existing_matches = tournament.matches existing_match_scores = existing_matches.flat_map(&:match_scores) existing_group_scores = existing_groups.flat_map(&:group_scores) + existing_team_action_lists = tournament.team_action_lists.to_a + existing_team_action_items = existing_team_action_lists.flat_map(&:team_action_items) update_tournament! teams_by_source_id = sync_teams! @@ -41,7 +43,11 @@ class TournamentSnapshotImporter matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id) sync_match_scores!(matches_by_source_id, teams_by_source_id) sync_group_scores!(groups_by_source_id, teams_by_source_id) + team_action_lists_by_source_id = sync_team_action_lists! + sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id) + destroy_stale_records(existing_team_action_items, snapshot.fetch(:team_action_items, [])) + destroy_stale_records(existing_team_action_lists, snapshot.fetch(:team_action_lists, [])) destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, [])) destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, [])) destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, [])) @@ -162,6 +168,33 @@ class TournamentSnapshotImporter end end + def sync_team_action_lists! + sync_records( + model_class: TeamActionList, + existing_records: tournament.team_action_lists.to_a, + payload: snapshot.fetch(:team_action_lists, []) + ) do |team_action_list, attrs| + team_action_list.tournament = tournament + team_action_list.name = attrs.fetch(:name) + team_action_list.key = attrs.fetch(:key) + team_action_list.action_name = attrs.fetch(:action_name) + team_action_list.source = attrs.fetch(:source) + end + end + + def sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id) + sync_records( + model_class: TeamActionItem, + existing_records: tournament.team_action_lists.flat_map(&:team_action_items), + payload: snapshot.fetch(:team_action_items, []) + ) do |team_action_item, attrs| + team_action_item.team_action_list = team_action_lists_by_source_id.fetch(attrs.fetch(:team_action_list_id)) + team_action_item.team = teams_by_source_id.fetch(attrs.fetch(:team_id)) + team_action_item.completed = attrs.fetch(:completed) + team_action_item.completed_at = parse_time(attrs[:completed_at]) + end + end + def sync_records(model_class:, existing_records:, payload:) existing_by_source_id = existing_records.index_by(&:sync_source_id) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 230c908..d31f49f 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -54,6 +54,14 @@ class TournamentSyncSchema GroupScore => { synced: %w[id group_points scored_points received_points team_id group_id position].freeze, ignored: %w[created_at updated_at sync_source_id].freeze + }.freeze, + TeamActionList => { + synced: %w[id name key action_name source].freeze, + ignored: %w[tournament_id created_at updated_at sync_source_id].freeze + }.freeze, + TeamActionItem => { + synced: %w[id team_action_list_id team_id completed completed_at].freeze, + ignored: %w[created_at updated_at sync_source_id].freeze }.freeze }.freeze diff --git a/config/routes.rb b/config/routes.rb index b3dcc6e..1bd4721 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ Rails.application.routes.draw do end resources :stages, only: %i[show update] resources :teams, only: %i[show update] + resources :team_action_items, only: %i[update] resources :tournaments do resources :statistics, only: %i[index] resources :matches, only: %i[index] diff --git a/db/migrate/20260422100000_create_team_action_lists.rb b/db/migrate/20260422100000_create_team_action_lists.rb new file mode 100644 index 0000000..fa7547d --- /dev/null +++ b/db/migrate/20260422100000_create_team_action_lists.rb @@ -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 diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 355cd6e..d6e5ab0 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams) .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) end + + it 'creates playoff token collection list for advancing teams' do + tournament = running_group_stage.tournament.reload + list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY) + + expect(list.name).to eq('Playoff token collection') + expect(list.team_action_items.includes(:team).map(&:team)) + .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) + expect(list.team_action_items.pluck(:completed).uniq).to eq([false]) + end end context 'as another user' do diff --git a/spec/controllers/team_action_items_controller_spec.rb b/spec/controllers/team_action_items_controller_spec.rb new file mode 100644 index 0000000..5eac4ef --- /dev/null +++ b/spec/controllers/team_action_items_controller_spec.rb @@ -0,0 +1,45 @@ +# 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 '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 diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 4f53e0a..6939459 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do expected_keys = %i[ id name code public description playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - timer_end owner_username stages teams + timer_end owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) expect(json[:name]).to eq(@tournament.name) @@ -204,6 +204,19 @@ RSpec.describe TournamentsController, type: :controller do advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) } expect(advancing_teams).to match_array(expected_advancing_teams) end + + it 'includes team action lists and item status for all viewers' do + list = create(:team_action_list, tournament: @group_stage_tournament) + create(:team_action_item, team_action_list: list, team: @group_stage_tournament.teams.first, completed: true) + allow(GroupStageService).to receive(:get_advancing_teams).and_return([]) + + get :show, params: { id: @group_stage_tournament.to_param } + body = deserialize_response response + + expect(body[:team_action_lists].size).to eq(1) + expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens') + expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true) + end end end diff --git a/spec/factories/team_action_items.rb b/spec/factories/team_action_items.rb new file mode 100644 index 0000000..bd272f5 --- /dev/null +++ b/spec/factories/team_action_items.rb @@ -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 diff --git a/spec/factories/team_action_lists.rb b/spec/factories/team_action_lists.rb new file mode 100644 index 0000000..12e240c --- /dev/null +++ b/spec/factories/team_action_lists.rb @@ -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 diff --git a/spec/routing/team_action_items_routing_spec.rb b/spec/routing/team_action_items_routing_spec.rb new file mode 100644 index 0000000..12efa95 --- /dev/null +++ b/spec/routing/team_action_items_routing_spec.rb @@ -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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 4788941..d761fe8 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter do it 'replaces follower tournament graph using source ids as stable mapping keys' do source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr') + list = create(:team_action_list, tournament: source) + create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true) follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') snapshot = TournamentSnapshotBuilder.build(source) @@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.name).to eq(source.name) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) + expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count) + expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count) expect(follower.stages.count).to eq(source.stages.count) expect(follower.matches.count).to eq(source.matches.count) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) @@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timer_end: tournament.timer_end&.iso8601, teams: tournament.teams.order(:name).map { |team| team.name }, + team_action_lists: tournament.team_action_lists.order(:key).map do |list| + { + key: list.key, + name: list.name, + action_name: list.action_name, + source: list.source, + items: list.team_action_items.includes(:team).sort_by { |item| item.team.name }.map do |item| + { + team_name: item.team.name, + completed: item.completed, + completed_at: item.completed_at&.iso8601 + } + end + } + end, beamers: tournament.beamers.order(:name).map do |beamer| beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys end, From eea065bd7544cbdfc03ea93b5b0ed92ddb4c73eb Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 16:56:11 +0200 Subject: [PATCH 03/12] test: cover team action list follower sync --- spec/e2e/http/tournament_follow_sync_spec.rb | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1c2efa7..d0af1dd 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -82,6 +82,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + expect(playoff_token_list(source)).not_to be_nil + expect(playoff_token_list(follower)).not_to be_nil + expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) + .to match_array(playoff_token_list(source).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team) @@ -89,6 +93,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(locked_team_update[:status]).to eq(423) expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') + log_phase!('mark playoff token collected on source and verify follower mirrors it') + source_token_item = playoff_token_list(source).fetch(:team_action_items).first + source_token_update = source_owner_client.patch("/team_action_items/#{source_token_item.fetch(: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("/team_action_items/#{follower_token_item.fetch(:id)}", body: { completed: false }) + expect(locked_token_update[:status]).to eq(423) + expect(locked_token_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') + source_noise.concat(seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Playoff Noise', range: 2..4)) follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5)) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) @@ -392,6 +411,21 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), timer_end: tournament[:timer_end], teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) }, + team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list| + { + key: list.fetch(:key), + name: list.fetch(:name), + action_name: list.fetch(:action_name), + source: list.fetch(:source), + team_action_items: list.fetch(:team_action_items).map do |item| + { + team_name: item.dig(:team, :name), + completed: item.fetch(:completed), + completed_at: item[:completed_at] + } + end.sort_by { |item| item.fetch(:team_name) } + } + end, stages: tournament.fetch(:stages).sort_by { |stage| stage.fetch(:level) }.map do |stage| { level: stage.fetch(:level), @@ -432,4 +466,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do @unique_suffix_counter += 1 "#{SecureRandom.hex(3)}-#{@unique_suffix_counter}" end + + def playoff_token_list(tournament) + tournament.fetch(:team_action_lists, []).find do |list| + list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + end + end + + def find_team_action_item(tournament, team_name:) + playoff_token_list(tournament).fetch(:team_action_items).find do |item| + item.dig(:team, :name) == team_name + end + end + + def playoff_token_item_completed?(tournament, team_name:) + find_team_action_item(tournament, team_name: team_name).fetch(:completed) + end end From 443b7ff869833c20c4a98a246fd1e88e7e7f45dd Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 09:39:39 +0200 Subject: [PATCH 04/12] fix: sync team action item updates --- app/controllers/team_action_items_controller.rb | 5 +++++ spec/controllers/team_action_items_controller_spec.rb | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb index 8011ef6..b4f14ee 100644 --- a/app/controllers/team_action_items_controller.rb +++ b/app/controllers/team_action_items_controller.rb @@ -9,6 +9,7 @@ class TeamActionItemsController < ApplicationController # PATCH /team_action_items/1 def update if @team_action_item.update(team_action_item_params) + push_sync_if_needed!(@team_action_item.tournament) render json: @team_action_item else render json: @team_action_item.errors, status: :unprocessable_entity @@ -24,4 +25,8 @@ class TeamActionItemsController < ApplicationController def team_action_item_params params.slice(:completed).permit(:completed) end + + def push_sync_if_needed!(tournament) + TournamentSyncEnqueue.call(tournament) + end end diff --git a/spec/controllers/team_action_items_controller_spec.rb b/spec/controllers/team_action_items_controller_spec.rb index 5eac4ef..efebd7a 100644 --- a/spec/controllers/team_action_items_controller_spec.rb +++ b/spec/controllers/team_action_items_controller_spec.rb @@ -19,6 +19,14 @@ RSpec.describe TeamActionItemsController, type: :controller do 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')) From fa83923bf29da1a6bd78f524efe32c511c652063 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 10:08:19 +0200 Subject: [PATCH 05/12] docs: add team action list frontend guide --- .../team_action_lists_controller.rb | 38 ++++ config/routes.rb | 1 + doc/team_action_lists_api.md | 176 ++++++++++++++++++ .../team_action_lists_controller_spec.rb | 74 ++++++++ .../routing/team_action_lists_routing_spec.rb | 17 ++ 5 files changed, 306 insertions(+) create mode 100644 app/controllers/team_action_lists_controller.rb create mode 100644 doc/team_action_lists_api.md create mode 100644 spec/controllers/team_action_lists_controller_spec.rb create mode 100644 spec/routing/team_action_lists_routing_spec.rb diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb new file mode 100644 index 0000000..06c13c5 --- /dev/null +++ b/app/controllers/team_action_lists_controller.rb @@ -0,0 +1,38 @@ +# 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 @team_action_item.update(team_action_item_params) + push_sync_if_needed!(@tournament) + 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 + + def push_sync_if_needed!(tournament) + TournamentSyncEnqueue.call(tournament) + end +end diff --git a/config/routes.rb b/config/routes.rb index 1bd4721..e4fac80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,7 @@ Rails.application.routes.draw do resources :teams, only: %i[show update] resources :team_action_items, only: %i[update] resources :tournaments do + patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] resources :matches, only: %i[index] resources :beamers, only: %i[index show create update destroy] do diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md new file mode 100644 index 0000000..24a29d6 --- /dev/null +++ b/doc/team_action_lists_api.md @@ -0,0 +1,176 @@ +# 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 + +## 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 diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb new file mode 100644 index 0000000..fdb9322 --- /dev/null +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -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 diff --git a/spec/routing/team_action_lists_routing_spec.rb b/spec/routing/team_action_lists_routing_spec.rb new file mode 100644 index 0000000..121778e --- /dev/null +++ b/spec/routing/team_action_lists_routing_spec.rb @@ -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 From f81a8fb83eec7db48368db65d2f14d6d487eb300 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 10:11:43 +0200 Subject: [PATCH 06/12] test: cover team action list update flows --- .../team_action_items_controller.rb | 10 +-- .../team_action_lists_controller.rb | 10 +-- app/services/team_action_item_update.rb | 25 ++++++ spec/e2e/http/tournament_follow_sync_spec.rb | 12 ++- spec/e2e/http/tournament_sync_test_spec.rb | 79 +++++++++++++++++++ 5 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 app/services/team_action_item_update.rb diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb index b4f14ee..b63a5d0 100644 --- a/app/controllers/team_action_items_controller.rb +++ b/app/controllers/team_action_items_controller.rb @@ -8,8 +8,10 @@ class TeamActionItemsController < ApplicationController # PATCH /team_action_items/1 def update - if @team_action_item.update(team_action_item_params) - push_sync_if_needed!(@team_action_item.tournament) + 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 @@ -25,8 +27,4 @@ class TeamActionItemsController < ApplicationController def team_action_item_params params.slice(:completed).permit(:completed) end - - def push_sync_if_needed!(tournament) - TournamentSyncEnqueue.call(tournament) - end end diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index 06c13c5..bff4b94 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -9,8 +9,10 @@ class TeamActionListsController < ApplicationController # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id def update_item - if @team_action_item.update(team_action_item_params) - push_sync_if_needed!(@tournament) + 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 @@ -31,8 +33,4 @@ class TeamActionListsController < ApplicationController def team_action_item_params params.slice(:completed).permit(:completed) end - - def push_sync_if_needed!(tournament) - TournamentSyncEnqueue.call(tournament) - end end diff --git a/app/services/team_action_item_update.rb b/app/services/team_action_item_update.rb new file mode 100644 index 0000000..955051e --- /dev/null +++ b/app/services/team_action_item_update.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index d0af1dd..3e95176 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -93,9 +93,12 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(locked_team_update[:status]).to eq(423) expect(locked_team_update.dig(:json, :error)).to eq('Tournament is in read only follower mode') - log_phase!('mark playoff token collected on source and verify follower mirrors it') + 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("/team_action_items/#{source_token_item.fetch(:id)}", body: { completed: true }) + 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)) @@ -104,7 +107,10 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do 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("/team_action_items/#{follower_token_item.fetch(:id)}", body: { completed: false }) + 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') diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index c5b996e..5507f5e 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -54,6 +54,42 @@ RSpec.describe 'Tournament sync test HTTP E2E' do .to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) }) end + it 'syncs team action list changes made through item id route and business key route' do + follower = create_follower_tournament(name_prefix: 'Sync Action Follower') + leader = create_group_stage_tournament( + name_prefix: 'Sync Action Leader', + sync_target_url: "#{base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_auth_token: sync_token + ) + + finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + + leader_after_playoffs = fetch_tournament(leader.fetch(:id)) + token_list = find_playoff_token_list!(leader_after_playoffs) + first_item = token_list.fetch(:team_action_items).first + second_item = token_list.fetch(:team_action_items).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) + + update_by_business_key = client.patch( + "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + body: { completed: true } + ) + expect(update_by_business_key[:status]).to eq(200) + + test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync") + expect(test_sync[:status]).to eq(200) + + leader_after_update = fetch_tournament(leader.fetch(:id)) + follower_after_update = fetch_tournament(follower.fetch(:id)) + + expect(playoff_token_item_completed?(leader_after_update, team_name: first_item.dig(:team, :name))).to eq(true) + expect(playoff_token_item_completed?(leader_after_update, team_name: second_item.dig(:team, :name))).to eq(true) + expect(playoff_token_item_completed?(follower_after_update, team_name: first_item.dig(:team, :name))).to eq(true) + expect(playoff_token_item_completed?(follower_after_update, team_name: second_item.dig(:team, :name))).to eq(true) + end + def login_client api_client = TurniereE2E::ApiClient.new(base_url: base_url) response = api_client.login!(email: owner_email, password: owner_password) @@ -103,4 +139,47 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(response[:status]).to eq(200) response.fetch(:json) end + + def finish_group_stage_and_create_playoffs!(tournament_id) + tournament = fetch_tournament(tournament_id) + group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + + group_stage.fetch(:groups).each do |group| + group.fetch(:matches).each_with_index do |match, index| + play_match!(match.fetch(:id), first_score: 10 + index, second_score: index) + end + end + + finish_stage = client.patch("/stages/#{group_stage.fetch(:id)}", body: { state: 'finished' }) + expect(finish_stage[:status]).to eq(200) + end + + def play_match!(match_id, first_score:, second_score:) + match_response = anonymous_client.get("/matches/#{match_id}") + expect(match_response[:status]).to eq(200) + + start_response = client.patch("/matches/#{match_id}", body: { state: 'in_progress' }) + expect(start_response[:status]).to eq(200) + + ordered_scores = match_response.fetch(:json).fetch(:match_scores).sort_by { |score| score.fetch(:id) } + [first_score, second_score].each_with_index do |points, index| + score_update = client.patch("/match_scores/#{ordered_scores[index].fetch(:id)}", body: { points: points }) + expect(score_update[:status]).to eq(200) + end + + finish_response = client.patch("/matches/#{match_id}", body: { state: 'finished' }) + expect(finish_response[:status]).to eq(200) + end + + def find_playoff_token_list!(tournament) + tournament.fetch(:team_action_lists).find do |list| + list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + end || raise('playoff token list missing') + end + + def playoff_token_item_completed?(tournament, team_name:) + list = find_playoff_token_list!(tournament) + item = list.fetch(:team_action_items).find { |candidate| candidate.dig(:team, :name) == team_name } + item.fetch(:completed) + end end From 883638f4fd766d590cd7044505c848547ef281dc Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 10:19:47 +0200 Subject: [PATCH 07/12] feat: add websocket team action updates --- AGENTS.md | 17 ++ app/channels/application_cable/channel.rb | 6 + app/channels/application_cable/connection.rb | 6 + .../tournament_team_action_lists_channel.rb | 9 + app/models/team_action_item.rb | 10 + app/models/team_action_list.rb | 13 ++ app/services/team_action_list_broadcast.rb | 15 ++ .../team_action_list_broadcast_payload.rb | 22 +++ config/application.rb | 4 +- config/cable.yml | 8 + config/routes.rb | 2 + doc/team_action_lists_api.md | 65 +++++++ e2e/lib/action_cable_client.rb | 140 ++++++++++++++ .../tournament_follow_sync_websocket_spec.rb | 173 ++++++++++++++++++ ...nament_team_action_lists_websocket_spec.rb | 154 ++++++++++++++++ tasks.py | 8 +- 16 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/tournament_team_action_lists_channel.rb create mode 100644 app/services/team_action_list_broadcast.rb create mode 100644 app/services/team_action_list_broadcast_payload.rb create mode 100644 config/cable.yml create mode 100644 e2e/lib/action_cable_client.rb create mode 100644 spec/e2e/http/tournament_follow_sync_websocket_spec.rb create mode 100644 spec/e2e/http/tournament_team_action_lists_websocket_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 469f657..54d96fc 100644 --- a/AGENTS.md +++ b/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, diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..9aec230 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..8d6c2a1 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/channels/tournament_team_action_lists_channel.rb b/app/channels/tournament_team_action_lists_channel.rb new file mode 100644 index 0000000..763d370 --- /dev/null +++ b/app/channels/tournament_team_action_lists_channel.rb @@ -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 diff --git a/app/models/team_action_item.rb b/app/models/team_action_item.rb index 9724f59..c3e2228 100644 --- a/app/models/team_action_item.rb +++ b/app/models/team_action_item.rb @@ -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 diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb index cd68cc9..1cbedcf 100644 --- a/app/models/team_action_list.rb +++ b/app/models/team_action_list.rb @@ -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 diff --git a/app/services/team_action_list_broadcast.rb b/app/services/team_action_list_broadcast.rb new file mode 100644 index 0000000..56d19ed --- /dev/null +++ b/app/services/team_action_list_broadcast.rb @@ -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 diff --git a/app/services/team_action_list_broadcast_payload.rb b/app/services/team_action_list_broadcast_payload.rb new file mode 100644 index 0000000..f508240 --- /dev/null +++ b/app/services/team_action_list_broadcast_payload.rb @@ -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 diff --git a/config/application.rb b/config/application.rb index ba2e151..bd16c7a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..1b9f7a4 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,8 @@ +development: + adapter: async + +test: + adapter: async + +production: + adapter: async diff --git a/config/routes.rb b/config/routes.rb index e4fac80..75de6ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 24a29d6..4ec66fa 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -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: diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb new file mode 100644 index 0000000..b79cadc --- /dev/null +++ b/e2e/lib/action_cable_client.rb @@ -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 diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb new file mode 100644 index 0000000..15b5e6b --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb new file mode 100644 index 0000000..f5bfda2 --- /dev/null +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -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 diff --git a/tasks.py b/tasks.py index 6fd9f3d..284773a 100644 --- a/tasks.py +++ b/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, From 26b87b9ead69b3a03debaeef8d4ba0888fa9cf5f Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 11:06:40 +0200 Subject: [PATCH 08/12] fix: harden websocket e2e helpers --- e2e/lib/action_cable_client.rb | 12 ++++++------ spec/e2e/http/tournament_sync_test_spec.rb | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index b79cadc..64ee4cd 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -47,12 +47,16 @@ module TurniereE2E return if @closed @closed = true - driver.close - socket.close + @driver&.close + @socket&.close rescue IOError, SystemCallError nil end + def url + websocket_url + end + private attr_reader :base_url, :socket, :driver @@ -93,10 +97,6 @@ module TurniereE2E "#{uri.scheme}://#{uri.host}:#{uri.port}" end - def url - websocket_url - end - def write(data) socket.write(data) end diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 5507f5e..9d0b871 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -67,7 +67,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do 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).second + 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) From 40cc1a4cf2d047d67f6bc6bdedd7d680f88d7253 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 11:36:34 +0200 Subject: [PATCH 09/12] fix: make websocket driver callbacks public --- e2e/lib/action_cable_client.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index 64ee4cd..67cb3c5 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -57,6 +57,10 @@ module TurniereE2E websocket_url end + def write(data) + socket.write(data) + end + private attr_reader :base_url, :socket, :driver @@ -97,10 +101,6 @@ module TurniereE2E "#{uri.scheme}://#{uri.host}:#{uri.port}" end - def write(data) - socket.write(data) - end - def pump_until_open!(timeout: 10) deadline = Time.now + timeout while Time.now < deadline From 78518afbd5bf8af0172f27947d6e8806f48816f9 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 12:16:34 +0200 Subject: [PATCH 10/12] fix: preserve prefetched websocket payloads --- e2e/lib/action_cable_client.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index 67cb3c5..b07a265 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -30,10 +30,12 @@ module TurniereE2E ) ) - wait_for! do |message| + 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) @@ -136,5 +138,10 @@ module TurniereE2E 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 From 0f4ed8a0c305d4d6370b660e275b42ff66947fe7 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 12:50:17 +0200 Subject: [PATCH 11/12] fix: avoid rails array helper in websocket e2e --- spec/e2e/http/tournament_team_action_lists_websocket_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb index f5bfda2..4e21fec 100644 --- a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do 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 + 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) From e913eb215b476bc1bb3e0cd6fe38672e76a2c375 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 13:18:02 +0200 Subject: [PATCH 12/12] docs: clarify websocket contract --- AGENTS.md | 4 ++++ doc/team_action_lists_api.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 54d96fc..a26dc23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,7 @@ That means: ## 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: @@ -176,8 +177,11 @@ Practical rule: 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 diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 4ec66fa..9c1fd25 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -164,6 +164,12 @@ That means: 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 @@ -188,6 +194,9 @@ Behavior: - 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: @@ -225,6 +234,16 @@ Frontend guidance: - 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: