From afff8139948f8163c467bdcfe9567dba7d69abf4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 23:29:03 +0200 Subject: [PATCH 1/5] feat: add timer modes and websocket updates --- app/channels/tournament_timers_channel.rb | 9 ++ app/controllers/tournaments_controller.rb | 51 +++++++-- app/models/tournament.rb | 10 ++ app/serializers/tournament_serializer.rb | 2 + app/services/tournament_snapshot_builder.rb | 1 + app/services/tournament_snapshot_importer.rb | 1 + app/services/tournament_sync_schema.rb | 1 + app/services/tournament_timer_broadcast.rb | 15 +++ .../tournament_timer_broadcast_payload.rb | 14 +++ ...423133000_add_timer_mode_to_tournaments.rb | 5 + e2e/lib/action_cable_client.rb | 55 +++++---- .../tournaments_controller_spec.rb | 38 ++++++- spec/e2e/http/api_surface_spec.rb | 16 +++ ...nament_timer_follow_sync_websocket_spec.rb | 107 ++++++++++++++++++ .../http/tournament_timer_websocket_spec.rb | 86 ++++++++++++++ .../tournament_snapshot_importer_spec.rb | 2 + tasks.py | 3 +- 17 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 app/channels/tournament_timers_channel.rb create mode 100644 app/services/tournament_timer_broadcast.rb create mode 100644 app/services/tournament_timer_broadcast_payload.rb create mode 100644 db/migrate/20260423133000_add_timer_mode_to_tournaments.rb create mode 100644 spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb create mode 100644 spec/e2e/http/tournament_timer_websocket_spec.rb diff --git a/app/channels/tournament_timers_channel.rb b/app/channels/tournament_timers_channel.rb new file mode 100644 index 0000000..b630d13 --- /dev/null +++ b/app/channels/tournament_timers_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TournamentTimersChannel < ApplicationCable::Channel + def subscribed + tournament = Tournament.find(params[:tournament_id]) + stream_for tournament + transmit TournamentTimerBroadcastPayload.build(tournament) + end +end diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index c64e8a4..aaabd93 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -131,7 +131,7 @@ class TournamentsController < ApplicationController # GET /tournaments/:id/timer_end def timer_end - render json: { timer_end: serialize_timer_end(@tournament.timer_end) } + render json: serialize_timer_state(@tournament) end # PATCH /tournaments/:id/set_timer_end @@ -172,11 +172,17 @@ class TournamentsController < ApplicationController private def timer_end_params - { timer_end: params[:timer_end] } + { + timer_end: params[:timer_end], + timer_mode: params[:timer_mode] + } end - def serialize_timer_end(timer_end) - timer_end&.iso8601 + def serialize_timer_state(tournament) + { + timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode + } end def organize_teams_in_groups(teams) @@ -329,12 +335,33 @@ class TournamentsController < ApplicationController def validate_set_timer_end_params timer_end = params[:timer_end] timer_end_seconds = params[:timer_end_seconds] + timer_mode = params[:timer_mode] + clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) + + if clear_timer + if timer_end.present? || timer_end_seconds.present? || timer_mode.present? + return render json: { error: 'clear_timer cannot be combined with timer values or timer_mode' }, + status: :unprocessable_entity + end + + params[:timer_end] = nil + params[:timer_mode] = nil + return + end + + if timer_mode.present? && !%w[countdown countup].include?(timer_mode) + return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity + end # throw error if both timer_end and timer_end_seconds are present if timer_end.present? && timer_end_seconds.present? return render json: { error: 'Only one of timer_end or timer_end_seconds is allowed' }, status: :unprocessable_entity end + if timer_mode == 'countup' && timer_end_seconds.present? + return render json: { error: 'timer_end_seconds is only allowed for countdown mode' }, status: :unprocessable_entity + end + if timer_end_seconds.present? begin timer_end_seconds = Integer(timer_end_seconds) @@ -346,21 +373,29 @@ class TournamentsController < ApplicationController parsed_time = Time.zone.now + timer_end_seconds params[:timer_end] = parsed_time + params[:timer_mode] = 'countdown' elsif timer_end.present? begin parsed_time = Time.zone.parse(timer_end) if parsed_time.nil? - return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity - elsif !parsed_time.future? + return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity + elsif timer_mode != 'countup' && !parsed_time.future? return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity end params[:timer_end] = parsed_time + params[:timer_mode] = timer_mode.presence || 'countdown' rescue ArgumentError - return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity + return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity end + elsif timer_mode == 'countup' + params[:timer_end] = Time.zone.now + elsif timer_mode.present? + return render json: { error: 'timer_mode requires timer_end, timer_end_seconds, or clear_timer' }, + status: :unprocessable_entity else - return render json: { error: 'Timer end is required' }, status: :unprocessable_entity + return render json: { error: 'timer_end, timer_end_seconds, or clear_timer is required' }, + status: :unprocessable_entity end end end diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 729fdab..653f738 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -11,6 +11,7 @@ class Tournament < ApplicationRecord validates :name, presence: true validates :code, presence: true, uniqueness: true + validates :timer_mode, inclusion: { in: %w[countdown countup] }, allow_nil: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validate :sync_configuration_is_complete @@ -21,6 +22,7 @@ class Tournament < ApplicationRecord after_initialize :generate_code after_create_commit :ensure_default_beamer! before_validation :clear_follower_sync_token_when_disabling_read_only_mode + after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? def matches [stages.map(&:matches), stages.map { |s| s.groups.map(&:matches) }].flatten @@ -92,4 +94,12 @@ class Tournament < ApplicationRecord self.sync_auth_token = nil end + + def saved_change_to_timer_state? + saved_change_to_timer_end? || saved_change_to_timer_mode? + end + + def broadcast_timer_state_change + TournamentTimerBroadcast.call(id) + end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 9389365..abed8eb 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -15,6 +15,8 @@ class TournamentSerializer < SimpleTournamentSerializer object.timer_end&.iso8601 end + attribute :timer_mode + attribute :sync_last_pushed_at, if: :sync_metadata_visible? do object.sync_last_pushed_at&.iso8601 end diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 48b61d4..03a5226 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -23,6 +23,7 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) payload[:timer_end] = tournament.timer_end&.iso8601 + payload[:timer_mode] = tournament.timer_mode payload[:sync_generated_at] = Time.current.iso8601(6) payload[:teams] = teams_payload payload[:beamers] = beamers_payload diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 0a1657f..4841792 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -68,6 +68,7 @@ class TournamentSnapshotImporter root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:timer_end] = parse_time(snapshot[:timer_end]) + root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at tournament.update!(root_attrs) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index d31f49f..bf03726 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -13,6 +13,7 @@ class TournamentSyncSchema instant_finalists_amount intermediate_round_participants_amount timer_end + timer_mode ].freeze, ignored: %w[ user_id diff --git a/app/services/tournament_timer_broadcast.rb b/app/services/tournament_timer_broadcast.rb new file mode 100644 index 0000000..9d8e198 --- /dev/null +++ b/app/services/tournament_timer_broadcast.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TournamentTimerBroadcast + class << self + def call(tournament_id) + tournament = Tournament.find_by(id: tournament_id) + return if tournament.nil? + + TournamentTimersChannel.broadcast_to( + tournament, + TournamentTimerBroadcastPayload.build(tournament) + ) + end + end +end diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb new file mode 100644 index 0000000..5640165 --- /dev/null +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TournamentTimerBroadcastPayload + class << self + def build(tournament) + { + type: 'timer.updated', + tournament_id: tournament.id, + timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode + } + end + end +end diff --git a/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb b/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb new file mode 100644 index 0000000..07f5ef2 --- /dev/null +++ b/db/migrate/20260423133000_add_timer_mode_to_tournaments.rb @@ -0,0 +1,5 @@ +class AddTimerModeToTournaments < ActiveRecord::Migration[7.0] + def change + add_column :tournaments, :timer_mode, :string + end +end diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index b07a265..a412813 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -17,25 +17,11 @@ module TurniereE2E end def subscribe_team_action_lists!(tournament_id:) - connect! - identifier = { - channel: 'TournamentTeamActionListsChannel', - tournament_id: tournament_id - }.to_json + subscribe!(channel: 'TournamentTeamActionListsChannel', tournament_id: tournament_id) + end - driver.text( - JSON.dump( - command: 'subscribe', - identifier: identifier - ) - ) - - handshake_message = wait_for! do |message| - message['type'] == 'confirm_subscription' || - (message['identifier'] == identifier && message['message'].is_a?(Hash)) - end - - @messages << handshake_message if team_action_lists_payload_message?(handshake_message) + def subscribe_timer!(tournament_id:) + subscribe!(channel: 'TournamentTimersChannel', tournament_id: tournament_id) end def wait_for_payload!(timeout: 20) @@ -45,6 +31,13 @@ module TurniereE2E end.fetch('message') end + def wait_for_timer_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + payload = message['message'] + payload.is_a?(Hash) && payload['type'] == 'timer.updated' + end.fetch('message') + end + def close return if @closed @@ -78,6 +71,28 @@ module TurniereE2E pump_until_open! end + def subscribe!(channel:, tournament_id:) + connect! + identifier = { + channel: channel, + tournament_id: tournament_id + }.to_json + + driver.text( + JSON.dump( + command: 'subscribe', + identifier: identifier + ) + ) + + handshake_message = wait_for! do |message| + message['type'] == 'confirm_subscription' || + (message['identifier'] == identifier && message['message'].is_a?(Hash)) + end + + @messages << handshake_message if payload_message?(handshake_message) + end + def build_socket uri = URI.parse(websocket_url) tcp_socket = TCPSocket.new(uri.host, uri.port) @@ -139,9 +154,9 @@ module TurniereE2E nil end - def team_action_lists_payload_message?(message) + def payload_message?(message) payload = message['message'] - payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' + payload.is_a?(Hash) && %w[team_action_lists.updated timer.updated].include?(payload['type']) end end end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 6939459..99fb5b7 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 team_action_lists + timer_end timer_mode owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) expect(json[:name]).to eq(@tournament.name) @@ -221,14 +221,15 @@ RSpec.describe TournamentsController, type: :controller do end describe 'GET #timer_end' do - it 'returns timer_end in ISO 8601 format' do + it 'returns timer state in ISO 8601 format' do timer_end = Time.utc(2026, 4, 13, 18, 0, 0) - @tournament.update!(timer_end: timer_end) + @tournament.update!(timer_end: timer_end, timer_mode: 'countdown') get :timer_end, params: { id: @tournament.to_param } expect(response).to be_successful expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end end @@ -245,6 +246,37 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to be_successful expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timer_end).utc.iso8601) expect(deserialize_response(response)[:timer_end]).to eq(@tournament.timer_end.iso8601) + expect(deserialize_response(response)[:timer_mode]).to eq('countdown') + end + + it 'starts countup timer at current time when timer_mode=countup' do + before_request = Time.zone.now + + patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup' } + + expect(response).to be_successful + expect(@tournament.reload.timer_mode).to eq('countup') + expect(@tournament.timer_end).to be_between(before_request - 1.second, Time.zone.now + 1.second) + expect(deserialize_response(response)[:timer_mode]).to eq('countup') + end + + it 'clears timer state when clear_timer=true' do + @tournament.update!(timer_end: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') + + patch :set_timer_end, params: { id: @tournament.to_param, clear_timer: true } + + expect(response).to be_successful + expect(@tournament.reload.timer_end).to be_nil + expect(@tournament.timer_mode).to be_nil + expect(deserialize_response(response)[:timer_end]).to be_nil + expect(deserialize_response(response)[:timer_mode]).to be_nil + end + + it 'rejects timer_end_seconds for countup mode' do + patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup', timer_end_seconds: 120 } + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('timer_end_seconds is only allowed for countdown mode') end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 7829092..e6be9a3 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -127,12 +127,18 @@ RSpec.describe 'Backend API surface HTTP E2E' do timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer_end") + timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120, timer_end: (Time.now.utc + 240).iso8601 }) timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 'nope' }) timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 0 }) + timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { + timer_mode: 'countup', + timer_end_seconds: 60 + }) timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 60 }) destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}") @@ -155,8 +161,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(timer_seconds[:status]).to eq(200) expect(timer_seconds.dig(:json, :timer_end)).to be_a(String) + expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') expect(timer_show[:status]).to eq(200) expect(timer_show.dig(:json, :timer_end)).to eq(timer_seconds.dig(:json, :timer_end)) + expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') + expect(timer_countup[:status]).to eq(200) + expect(timer_countup.dig(:json, :timer_end)).to be_a(String) + expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') + expect(timer_clear[:status]).to eq(200) + expect(timer_clear.dig(:json, :timer_end)).to be_nil + expect(timer_clear.dig(:json, :timer_mode)).to be_nil expect(timer_double[:status]).to eq(422) expect(timer_double.dig(:json, :error)).to eq('Only one of timer_end or timer_end_seconds is allowed') @@ -164,6 +178,8 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_past[:status]).to eq(422) expect(timer_past.dig(:json, :error)).to eq('Timer end must be in the future') + expect(timer_countup_seconds[:status]).to eq(422) + expect(timer_countup_seconds.dig(:json, :error)).to eq('timer_end_seconds is only allowed for countdown mode') expect(timer_forbidden[:status]).to eq(403) expect([200, 204]).to include(destroy_response[:status]) diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb new file mode 100644 index 0000000..2511b83 --- /dev/null +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -0,0 +1,107 @@ +# 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 timer 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) } + + it 'broadcasts follower timer websocket updates after source sync import' do + source = create_playoff_tournament(client: source_client, name_prefix: 'Ws Timer Source') + follower = create_follower_tournament(name_prefix: 'Ws Timer 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) + + cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + cable_client.subscribe_timer!(tournament_id: follower.fetch(:id)) + initial_payload = cable_client.wait_for_timer_payload! + expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timer_mode')).to be_nil + + countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + expect(countdown[:status]).to eq(200) + + countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') + expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + + countup = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + expect(countup[:status]).to eq(200) + + countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') + expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + + clear = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + expect(clear[:status]).to eq(200) + + cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) + expect(cleared_payload.fetch('timer_end')).to be_nil + 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_playoff_tournament(client:, name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower websocket timer source tournament', + public: false, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + 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 timer target tournament', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('timer_mode') == timer_mode + + raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb new file mode 100644 index 0000000..9fd18c3 --- /dev/null +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -0,0 +1,86 @@ +# 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 timer 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 } + + it 'broadcasts timer state changes over websocket' do + tournament = create_playoff_tournament(name_prefix: 'Ws Timer') + + cable_client = TurniereE2E::ActionCableClient.new(base_url: base_url) + cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id)) + + initial_payload = cable_client.wait_for_timer_payload! + expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timer_mode')).to be_nil + + countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + expect(countdown[:status]).to eq(200) + + countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') + expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + + countup = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + expect(countup[:status]).to eq(200) + + countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') + expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + + clear = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + expect(clear[:status]).to eq(200) + + cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) + expect(cleared_payload.fetch('timer_end')).to be_nil + 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_playoff_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Websocket timer tournament', + public: false, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-#{index + 1}" } + end + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('timer_mode') == timer_mode + + raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index e315c18..25d9d0b 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -18,6 +18,7 @@ RSpec.describe TournamentSnapshotImporter do follower.reload expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.name).to eq(source.name) + expect(follower.timer_mode).to eq(source.timer_mode) 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) @@ -183,6 +184,7 @@ RSpec.describe TournamentSnapshotImporter do instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, timer_end: tournament.timer_end&.iso8601, + timer_mode: tournament.timer_mode, teams: tournament.teams.order(:name).map { |team| team.name }, team_action_lists: tournament.team_action_lists.order(:key).map do |list| { diff --git a/tasks.py b/tasks.py index 284773a..59e5229 100644 --- a/tasks.py +++ b/tasks.py @@ -1125,7 +1125,7 @@ def docker_blackbox_follow_test( ] full_command = _docker_compose_cmd(project_name=BLACKBOX_FOLLOW_PROJECT, compose_file=BLACKBOX_FOLLOW_COMPOSE_FILE) + command print(_format_command(full_command), flush=True) - result = _run_subprocess_live(full_command, env=env, check=False) + result = _stream_subprocess(full_command, env=env, check=False) if result.returncode != 0: raise subprocess.CalledProcessError( @@ -1696,6 +1696,7 @@ def blackbox_follow_service( spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", + "spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb", ], extra_env={ "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, From 8d882ad763fe4bc06822218914eecb2b3b97f71d Mon Sep 17 00:00:00 2001 From: Malaber Date: Fri, 24 Apr 2026 07:50:32 +0200 Subject: [PATCH 2/5] docs: add timer frontend integration guide --- README.md | 1 + doc/timer_api.md | 325 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 doc/timer_api.md diff --git a/README.md b/README.md index 90bf18f..670b9c6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ bundle exec rspec Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) +- [doc/timer_api.md](doc/timer_api.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) diff --git a/doc/timer_api.md b/doc/timer_api.md new file mode 100644 index 0000000..8e63d04 --- /dev/null +++ b/doc/timer_api.md @@ -0,0 +1,325 @@ +# Tournament Timer API + +## Purpose + +Backend timer supports: + +- countdown mode +- countup mode +- clear / unset +- live websocket updates +- follower sync propagation + +Frontend should treat timer state as small state machine: + +- no timer: `timer_end = null`, `timer_mode = null` +- countdown: `timer_mode = "countdown"` +- countup: `timer_mode = "countup"` + +## Read Flow + +Timer state is available in normal tournament payload: + +- `GET /tournaments/:id` + +Dedicated timer endpoint also exists: + +- `GET /tournaments/:id/timer_end` + +Response shape: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z", + "timer_mode": "countdown" +} +``` + +Possible values: + +- `timer_mode = "countdown"` +- `timer_mode = "countup"` +- `timer_mode = null` + +## Meaning Of `timer_end` + +Name old. Semantics now depend on mode. + +### Countdown mode + +`timer_end` means exact wall-clock timestamp when countdown reaches zero. + +Frontend math: + +```text +remaining_ms = max(0, timer_end - now) +``` + +### Countup mode + +`timer_end` means anchor timestamp when countup started. + +It is **not** finish time in this mode. +Think of it as: + +- timer start time +- elapsed-time anchor + +Frontend math: + +```text +elapsed_ms = max(0, now - timer_end) +``` + +Practical example: + +- backend returns: + - `timer_mode = "countup"` + - `timer_end = "2026-04-24T10:00:00Z"` +- current time: + - `2026-04-24T10:03:15Z"` + +Frontend should display about `03:15` elapsed. + +### Unset timer + +- `timer_mode = null` +- `timer_end = null` + +Frontend should render no active timer. + +## Write Flow + +Only tournament owner may change timer. +Follower tournaments in `read_only_mode` reject writes with `423`. + +Endpoint: + +- `PATCH /tournaments/:id/set_timer_end` + +### Start countdown by duration + +Request: + +```json +{ + "timer_end_seconds": 120 +} +``` + +Behavior: + +- backend computes `Time.zone.now + 120` +- backend stores: + - `timer_mode = "countdown"` + - `timer_end = computed future timestamp` + +### Start countdown by explicit timestamp + +Request: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z" +} +``` + +Optional explicit mode: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z", + "timer_mode": "countdown" +} +``` + +### Start countup + +Request: + +```json +{ + "timer_mode": "countup" +} +``` + +Behavior: + +- backend stores current server time into `timer_end` +- backend stores `timer_mode = "countup"` + +Frontend should interpret returned `timer_end` as start time anchor. + +### Restore countup from explicit anchor + +Backend also accepts explicit timestamp with countup mode: + +```json +{ + "timer_end": "2026-04-24T10:00:00Z", + "timer_mode": "countup" +} +``` + +Useful for: + +- restoring imported state +- rehydrating synced follower state +- advanced admin tooling + +### Clear timer + +Request: + +```json +{ + "clear_timer": true +} +``` + +Behavior: + +- backend clears both fields +- response returns: + - `timer_end = null` + - `timer_mode = null` + +## Validation Rules + +Backend rejects: + +- both `timer_end` and `timer_end_seconds` +- invalid `timer_mode` +- `timer_end_seconds <= 0` +- `timer_end_seconds` together with `timer_mode = "countup"` +- `timer_mode` by itself unless it is `countup` +- `clear_timer` mixed with timer values + +Countdown timestamps must be future timestamps. +Countup timestamps may be current or past timestamps. + +## Websocket / Live Updates + +Timer uses ActionCable websocket broadcast. +This is websocket push, not webhook callback. + +Channel: + +- `TournamentTimersChannel` + +Subscription identifier: + +```json +{ + "channel": "TournamentTimersChannel", + "tournament_id": 123 +} +``` + +Payload: + +```json +{ + "type": "timer.updated", + "tournament_id": 123, + "timer_end": "2026-04-24T10:15:00Z", + "timer_mode": "countdown" +} +``` + +Behavior: + +- subscription immediately receives current timer snapshot +- later timer changes broadcast full replacement snapshot +- direct owner edits and follower sync imports both use same broadcast path + +Frontend rule: + +- replace local timer state from websocket payload +- do not merge by guessing transitions + +## Frontend Implementation Guide + +Recommended flow: + +1. Load tournament via `GET /tournaments/:id` +2. Read `timer_mode` and `timer_end` +3. Open ActionCable subscription for same tournament id +4. Replace local timer state whenever `timer.updated` arrives +5. Derive displayed time from `Date.now()` plus stored server timestamp + +Recommended client state: + +```ts +type TimerState = + | { mode: null; anchorIso: null } + | { mode: "countdown"; anchorIso: string } + | { mode: "countup"; anchorIso: string }; +``` + +Suggested derivation logic: + +```ts +function getTimerDisplay(state: TimerState, nowMs: number) { + if (state.mode === null || state.anchorIso === null) { + return { active: false, ms: 0 }; + } + + const anchorMs = new Date(state.anchorIso).getTime(); + + if (state.mode === "countdown") { + return { active: true, ms: Math.max(0, anchorMs - nowMs) }; + } + + return { active: true, ms: Math.max(0, nowMs - anchorMs) }; +} +``` + +Recommended rendering: + +- `countdown`: show remaining time +- `countup`: show elapsed time +- unset: hide timer or show inactive state + +Recommended local ticking: + +- keep backend state as timestamp + mode +- update rendered value with 250ms or 1s local interval +- do not poll backend every second + +Recommended write UX: + +- owner actions send explicit target request +- after successful response, update local state from response body +- websocket should converge all viewers to same state + +## Follower Behavior + +Timer state is included in tournament sync snapshots. + +That means: + +- leader timer changes propagate to follower +- follower tournament exposes same `timer_end` / `timer_mode` +- follower websocket subscribers receive `timer.updated` +- follower still rejects timer writes while read only + +## E2E Coverage + +Implemented coverage: + +- API surface timer lifecycle: + - countdown + - countup + - clear + - validation + - [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb) +- direct websocket timer updates: + - [spec/e2e/http/tournament_timer_websocket_spec.rb](../spec/e2e/http/tournament_timer_websocket_spec.rb) +- follower websocket timer propagation: + - [spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb](../spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb) + +Current note: + +- single-backend HTTP E2E timer specs pass +- follower blackbox task still has environment/network issue in existing Docker follow setup, so production-style follow stack verification is not green yet From 69bd43b28883e10d90279171af59d947197e2c57 Mon Sep 17 00:00:00 2001 From: Malaber Date: Fri, 24 Apr 2026 10:13:44 +0200 Subject: [PATCH 3/5] refactor: rename timer API field to timestamp --- app/controllers/tournaments_controller.rb | 47 +++++------ app/serializers/tournament_serializer.rb | 2 +- app/services/tournament_snapshot_builder.rb | 2 +- app/services/tournament_snapshot_importer.rb | 2 +- .../tournament_timer_broadcast_payload.rb | 2 +- doc/timer_api.md | 77 ++++++++++--------- e2e/lib/scenario_runner.rb | 4 +- .../tournaments_controller_spec.rb | 22 +++--- spec/e2e/http/api_surface_spec.rb | 28 +++---- spec/e2e/http/tournament_follow_sync_spec.rb | 2 +- ...nament_timer_follow_sync_websocket_spec.rb | 10 +-- .../http/tournament_timer_websocket_spec.rb | 10 +-- .../tournament_snapshot_importer_spec.rb | 2 +- 13 files changed, 108 insertions(+), 102 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index aaabd93..f12b169 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -173,14 +173,14 @@ class TournamentsController < ApplicationController def timer_end_params { - timer_end: params[:timer_end], + timer_end: params[:timer_end] || params[:timestamp], timer_mode: params[:timer_mode] } end def serialize_timer_state(tournament) { - timer_end: tournament.timer_end&.iso8601, + timestamp: tournament.timer_end&.iso8601, timer_mode: tournament.timer_mode } end @@ -333,14 +333,17 @@ class TournamentsController < ApplicationController end def validate_set_timer_end_params - timer_end = params[:timer_end] - timer_end_seconds = params[:timer_end_seconds] + timestamp = params[:timestamp] + timestamp_seconds = params[:timestamp_seconds] timer_mode = params[:timer_mode] clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) + timestamp = params[:timer_end] if timestamp.blank? && params[:timer_end].present? + timestamp_seconds = params[:timer_end_seconds] if timestamp_seconds.blank? && params[:timer_end_seconds].present? + if clear_timer - if timer_end.present? || timer_end_seconds.present? || timer_mode.present? - return render json: { error: 'clear_timer cannot be combined with timer values or timer_mode' }, + if timestamp.present? || timestamp_seconds.present? || timer_mode.present? + return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' }, status: :unprocessable_entity end @@ -353,48 +356,48 @@ class TournamentsController < ApplicationController return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity end - # throw error if both timer_end and timer_end_seconds are present - if timer_end.present? && timer_end_seconds.present? - return render json: { error: 'Only one of timer_end or timer_end_seconds is allowed' }, status: :unprocessable_entity + # throw error if both timestamp and timestamp_seconds are present + if timestamp.present? && timestamp_seconds.present? + return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, status: :unprocessable_entity end - if timer_mode == 'countup' && timer_end_seconds.present? - return render json: { error: 'timer_end_seconds is only allowed for countdown mode' }, status: :unprocessable_entity + if timer_mode == 'countup' && timestamp_seconds.present? + return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, status: :unprocessable_entity end - if timer_end_seconds.present? + if timestamp_seconds.present? begin - timer_end_seconds = Integer(timer_end_seconds) + timestamp_seconds = Integer(timestamp_seconds) rescue ArgumentError return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity end - return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity if timer_end_seconds <= 0 + return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0 - parsed_time = Time.zone.now + timer_end_seconds + parsed_time = Time.zone.now + timestamp_seconds params[:timer_end] = parsed_time params[:timer_mode] = 'countdown' - elsif timer_end.present? + elsif timestamp.present? begin - parsed_time = Time.zone.parse(timer_end) + parsed_time = Time.zone.parse(timestamp) if parsed_time.nil? - return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity + return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity elsif timer_mode != 'countup' && !parsed_time.future? - return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity + return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity end params[:timer_end] = parsed_time params[:timer_mode] = timer_mode.presence || 'countdown' rescue ArgumentError - return render json: { error: 'Invalid timer_end format' }, status: :unprocessable_entity + return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity end elsif timer_mode == 'countup' params[:timer_end] = Time.zone.now elsif timer_mode.present? - return render json: { error: 'timer_mode requires timer_end, timer_end_seconds, or clear_timer' }, + return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, status: :unprocessable_entity else - return render json: { error: 'timer_end, timer_end_seconds, or clear_timer is required' }, + return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' }, status: :unprocessable_entity end end diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index abed8eb..e5d525a 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -11,7 +11,7 @@ class TournamentSerializer < SimpleTournamentSerializer has_many :stages has_many :team_action_lists - attribute :timer_end do + attribute :timestamp do object.timer_end&.iso8601 end diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 03a5226..9932cc8 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -22,7 +22,7 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) - payload[:timer_end] = tournament.timer_end&.iso8601 + payload[:timestamp] = tournament.timer_end&.iso8601 payload[:timer_mode] = tournament.timer_mode payload[:sync_generated_at] = Time.current.iso8601(6) payload[:teams] = teams_payload diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 4841792..4e1af83 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -67,7 +67,7 @@ class TournamentSnapshotImporter def update_tournament! root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) - root_attrs[:timer_end] = parse_time(snapshot[:timer_end]) + root_attrs[:timer_end] = parse_time(snapshot[:timestamp] || snapshot[:timer_end]) root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb index 5640165..97fa866 100644 --- a/app/services/tournament_timer_broadcast_payload.rb +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -6,7 +6,7 @@ class TournamentTimerBroadcastPayload { type: 'timer.updated', tournament_id: tournament.id, - timer_end: tournament.timer_end&.iso8601, + timestamp: tournament.timer_end&.iso8601, timer_mode: tournament.timer_mode } end diff --git a/doc/timer_api.md b/doc/timer_api.md index 8e63d04..3f170fd 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -10,9 +10,9 @@ Backend timer supports: - live websocket updates - follower sync propagation -Frontend should treat timer state as small state machine: +Frontend should treat timer state as: -- no timer: `timer_end = null`, `timer_mode = null` +- no timer: `timestamp = null`, `timer_mode = null` - countdown: `timer_mode = "countdown"` - countup: `timer_mode = "countup"` @@ -30,7 +30,7 @@ Response shape: ```json { - "timer_end": "2026-04-24T10:15:00Z", + "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` @@ -41,23 +41,23 @@ Possible values: - `timer_mode = "countup"` - `timer_mode = null` -## Meaning Of `timer_end` +## Meaning Of `timestamp` -Name old. Semantics now depend on mode. +Same field. Mode decides meaning. ### Countdown mode -`timer_end` means exact wall-clock timestamp when countdown reaches zero. +`timestamp` means exact wall-clock timestamp when countdown reaches zero. Frontend math: ```text -remaining_ms = max(0, timer_end - now) +remaining_ms = max(0, timestamp - now) ``` ### Countup mode -`timer_end` means anchor timestamp when countup started. +`timestamp` means anchor timestamp when countup started. It is **not** finish time in this mode. Think of it as: @@ -68,23 +68,23 @@ Think of it as: Frontend math: ```text -elapsed_ms = max(0, now - timer_end) +elapsed_ms = max(0, now - timestamp) ``` Practical example: - backend returns: - `timer_mode = "countup"` - - `timer_end = "2026-04-24T10:00:00Z"` + - `timestamp = "2026-04-24T10:00:00Z"` - current time: - - `2026-04-24T10:03:15Z"` + - `2026-04-24T10:03:15Z` Frontend should display about `03:15` elapsed. ### Unset timer - `timer_mode = null` -- `timer_end = null` +- `timestamp = null` Frontend should render no active timer. @@ -97,13 +97,16 @@ Endpoint: - `PATCH /tournaments/:id/set_timer_end` +Current route name still says `set_timer_end`. +Payload field name is now `timestamp`. + ### Start countdown by duration Request: ```json { - "timer_end_seconds": 120 + "timestamp_seconds": 120 } ``` @@ -112,7 +115,7 @@ Behavior: - backend computes `Time.zone.now + 120` - backend stores: - `timer_mode = "countdown"` - - `timer_end = computed future timestamp` + - `timestamp = computed future timestamp` ### Start countdown by explicit timestamp @@ -120,7 +123,7 @@ Request: ```json { - "timer_end": "2026-04-24T10:15:00Z" + "timestamp": "2026-04-24T10:15:00Z" } ``` @@ -128,7 +131,7 @@ Optional explicit mode: ```json { - "timer_end": "2026-04-24T10:15:00Z", + "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` @@ -145,10 +148,10 @@ Request: Behavior: -- backend stores current server time into `timer_end` +- backend stores current server time into `timestamp` - backend stores `timer_mode = "countup"` -Frontend should interpret returned `timer_end` as start time anchor. +Frontend should interpret returned `timestamp` as start time anchor. ### Restore countup from explicit anchor @@ -156,7 +159,7 @@ Backend also accepts explicit timestamp with countup mode: ```json { - "timer_end": "2026-04-24T10:00:00Z", + "timestamp": "2026-04-24T10:00:00Z", "timer_mode": "countup" } ``` @@ -181,17 +184,17 @@ Behavior: - backend clears both fields - response returns: - - `timer_end = null` + - `timestamp = null` - `timer_mode = null` ## Validation Rules Backend rejects: -- both `timer_end` and `timer_end_seconds` +- both `timestamp` and `timestamp_seconds` - invalid `timer_mode` -- `timer_end_seconds <= 0` -- `timer_end_seconds` together with `timer_mode = "countup"` +- `timestamp_seconds <= 0` +- `timestamp_seconds` together with `timer_mode = "countup"` - `timer_mode` by itself unless it is `countup` - `clear_timer` mixed with timer values @@ -222,7 +225,7 @@ Payload: { "type": "timer.updated", "tournament_id": 123, - "timer_end": "2026-04-24T10:15:00Z", + "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` @@ -243,35 +246,35 @@ Frontend rule: Recommended flow: 1. Load tournament via `GET /tournaments/:id` -2. Read `timer_mode` and `timer_end` +2. Read `timer_mode` and `timestamp` 3. Open ActionCable subscription for same tournament id 4. Replace local timer state whenever `timer.updated` arrives -5. Derive displayed time from `Date.now()` plus stored server timestamp +5. Derive displayed time from `Date.now()` plus stored backend timestamp Recommended client state: ```ts type TimerState = - | { mode: null; anchorIso: null } - | { mode: "countdown"; anchorIso: string } - | { mode: "countup"; anchorIso: string }; + | { mode: null; timestampIso: null } + | { mode: "countdown"; timestampIso: string } + | { mode: "countup"; timestampIso: string }; ``` Suggested derivation logic: ```ts function getTimerDisplay(state: TimerState, nowMs: number) { - if (state.mode === null || state.anchorIso === null) { + if (state.mode === null || state.timestampIso === null) { return { active: false, ms: 0 }; } - const anchorMs = new Date(state.anchorIso).getTime(); + const timestampMs = new Date(state.timestampIso).getTime(); if (state.mode === "countdown") { - return { active: true, ms: Math.max(0, anchorMs - nowMs) }; + return { active: true, ms: Math.max(0, timestampMs - nowMs) }; } - return { active: true, ms: Math.max(0, nowMs - anchorMs) }; + return { active: true, ms: Math.max(0, nowMs - timestampMs) }; } ``` @@ -284,13 +287,13 @@ Recommended rendering: Recommended local ticking: - keep backend state as timestamp + mode -- update rendered value with 250ms or 1s local interval +- update rendered value with local interval - do not poll backend every second Recommended write UX: - owner actions send explicit target request -- after successful response, update local state from response body +- after success, update local state from response body - websocket should converge all viewers to same state ## Follower Behavior @@ -300,7 +303,7 @@ Timer state is included in tournament sync snapshots. That means: - leader timer changes propagate to follower -- follower tournament exposes same `timer_end` / `timer_mode` +- follower tournament exposes same `timestamp` / `timer_mode` - follower websocket subscribers receive `timer.updated` - follower still rejects timer writes while read only @@ -322,4 +325,4 @@ Implemented coverage: Current note: - single-backend HTTP E2E timer specs pass -- follower blackbox task still has environment/network issue in existing Docker follow setup, so production-style follow stack verification is not green yet +- follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index d6d3b24..9ad0682 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -30,9 +30,9 @@ module TurniereE2E timer_response = patch!( "/tournaments/#{tournament.fetch(:id)}/set_timer_end", - body: { timer_end_seconds: 600 } + body: { timestamp_seconds: 600 } ) - checkpoints[:timer_set] = { timer_end: timer_response.fetch(:timer_end) } + checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) } return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set upcoming_matches = get!( diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 99fb5b7..f4c31cf 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 timer_mode owner_username stages teams team_action_lists + timestamp timer_mode owner_username stages teams team_action_lists ] expect(json.keys).to match_array(expected_keys) expect(json[:name]).to eq(@tournament.name) @@ -228,7 +228,7 @@ RSpec.describe TournamentsController, type: :controller do get :timer_end, params: { id: @tournament.to_param } expect(response).to be_successful - expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601) + expect(deserialize_response(response)[:timestamp]).to eq(timer_end.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end end @@ -238,14 +238,14 @@ RSpec.describe TournamentsController, type: :controller do apply_authentication_headers_for @user end - it 'stores timezone-aware timer_end values without shifting them' do - timer_end = '2030-04-13T18:00:00+02:00' + it 'stores timezone-aware timestamp values without shifting them' do + timestamp = '2030-04-13T18:00:00+02:00' - patch :set_timer_end, params: { id: @tournament.to_param, timer_end: timer_end } + patch :set_timer_end, params: { id: @tournament.to_param, timestamp: timestamp } expect(response).to be_successful - expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timer_end).utc.iso8601) - expect(deserialize_response(response)[:timer_end]).to eq(@tournament.timer_end.iso8601) + expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) + expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timer_end.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end @@ -268,15 +268,15 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to be_successful expect(@tournament.reload.timer_end).to be_nil expect(@tournament.timer_mode).to be_nil - expect(deserialize_response(response)[:timer_end]).to be_nil + expect(deserialize_response(response)[:timestamp]).to be_nil expect(deserialize_response(response)[:timer_mode]).to be_nil end - it 'rejects timer_end_seconds for countup mode' do - patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup', timer_end_seconds: 120 } + it 'rejects timestamp_seconds for countup mode' do + patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } expect(response).to have_http_status(:unprocessable_entity) - expect(deserialize_response(response)[:error]).to eq('timer_end_seconds is only allowed for countdown mode') + expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode') end end diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index e6be9a3..7db4ad6 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -125,21 +125,21 @@ RSpec.describe 'Backend API surface HTTP E2E' do forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' }) unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' }) - timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer_end") timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { - timer_end_seconds: 120, - timer_end: (Time.now.utc + 240).iso8601 + timestamp_seconds: 120, + timestamp: (Time.now.utc + 240).iso8601 }) - timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 'nope' }) - timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 0 }) + timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 'nope' }) + timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 0 }) timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup', - timer_end_seconds: 60 + timestamp_seconds: 60 }) - timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 60 }) + timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 60 }) destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}") after_destroy = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}") @@ -160,26 +160,26 @@ RSpec.describe 'Backend API surface HTTP E2E' do expect(unauthenticated_update[:status]).to eq(401) expect(timer_seconds[:status]).to eq(200) - expect(timer_seconds.dig(:json, :timer_end)).to be_a(String) + expect(timer_seconds.dig(:json, :timestamp)).to be_a(String) expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') expect(timer_show[:status]).to eq(200) - expect(timer_show.dig(:json, :timer_end)).to eq(timer_seconds.dig(:json, :timer_end)) + expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') expect(timer_countup[:status]).to eq(200) - expect(timer_countup.dig(:json, :timer_end)).to be_a(String) + expect(timer_countup.dig(:json, :timestamp)).to be_a(String) expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') expect(timer_clear[:status]).to eq(200) - expect(timer_clear.dig(:json, :timer_end)).to be_nil + expect(timer_clear.dig(:json, :timestamp)).to be_nil expect(timer_clear.dig(:json, :timer_mode)).to be_nil expect(timer_double[:status]).to eq(422) - expect(timer_double.dig(:json, :error)).to eq('Only one of timer_end or timer_end_seconds is allowed') + expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed') expect(timer_invalid_seconds[:status]).to eq(422) expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_past[:status]).to eq(422) - expect(timer_past.dig(:json, :error)).to eq('Timer end must be in the future') + expect(timer_past.dig(:json, :error)).to eq('Timestamp must be in the future for countdown mode') expect(timer_countup_seconds[:status]).to eq(422) - expect(timer_countup_seconds.dig(:json, :error)).to eq('timer_end_seconds is only allowed for countdown mode') + expect(timer_countup_seconds.dig(:json, :error)).to eq('timestamp_seconds is only allowed for countdown mode') expect(timer_forbidden[:status]).to eq(403) expect([200, 204]).to include(destroy_response[:status]) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1802516..90edc01 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -474,7 +474,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do playoff_teams_amount: tournament.fetch(:playoff_teams_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), - timer_end: tournament[:timer_end], + timestamp: tournament[:timestamp], 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| { diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb index 2511b83..c8e43db 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -38,26 +38,26 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) cable_client.subscribe_timer!(tournament_id: follower.fetch(:id)) initial_payload = cable_client.wait_for_timer_payload! - expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil - countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) expect(countdown[:status]).to eq(200) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') - expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) countup = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) expect(countup[:status]).to eq(200) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') - expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) clear = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { clear_timer: true }) expect(clear[:status]).to eq(200) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) - expect(cleared_payload.fetch('timer_end')).to be_nil + expect(cleared_payload.fetch('timestamp')).to be_nil ensure cable_client&.close end diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 9fd18c3..f9d423e 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -29,26 +29,26 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do cable_client.subscribe_timer!(tournament_id: tournament.fetch(:id)) initial_payload = cable_client.wait_for_timer_payload! - expect(initial_payload.fetch('timer_end')).to be_nil + expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil - countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_end_seconds: 120 }) + countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) expect(countdown[:status]).to eq(200) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') - expect(countdown_payload.fetch('timer_end')).to eq(countdown.dig(:json, :timer_end)) + expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) countup = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) expect(countup[:status]).to eq(200) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') - expect(countup_payload.fetch('timer_end')).to eq(countup.dig(:json, :timer_end)) + expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) clear = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) expect(clear[:status]).to eq(200) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) - expect(cleared_payload.fetch('timer_end')).to be_nil + expect(cleared_payload.fetch('timestamp')).to be_nil ensure cable_client&.close end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 25d9d0b..4c9d1ef 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -183,7 +183,7 @@ RSpec.describe TournamentSnapshotImporter do playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, - timer_end: tournament.timer_end&.iso8601, + timestamp: tournament.timer_end&.iso8601, timer_mode: tournament.timer_mode, teams: tournament.teams.order(:name).map { |team| team.name }, team_action_lists: tournament.team_action_lists.order(:key).map do |list| From 13acbad55fb12715fa2757e22b2461f552221ef7 Mon Sep 17 00:00:00 2001 From: Malaber Date: Fri, 24 Apr 2026 11:24:56 +0200 Subject: [PATCH 4/5] refactor: clean timer api routes --- app/controllers/tournaments_controller.rb | 29 +++++++++---------- app/services/tournament_snapshot_importer.rb | 2 +- config/routes.rb | 4 +-- doc/timer_api.md | 7 ++--- e2e/lib/scenario_runner.rb | 2 +- .../tournaments_controller_spec.rb | 16 +++++----- spec/e2e/http/api_surface_spec.rb | 18 ++++++------ ...nament_timer_follow_sync_websocket_spec.rb | 6 ++-- .../http/tournament_timer_websocket_spec.rb | 6 ++-- 9 files changed, 42 insertions(+), 48 deletions(-) diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index f12b169..d2e4233 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -12,15 +12,15 @@ class TournamentsController < ApplicationController end before_action :set_tournament_for_show, only: %i[show] - before_action :set_tournament, only: %i[update destroy set_timer_end timer_end sync_state test_sync] - before_action :authenticate_user!, only: %i[create update destroy set_timer_end test_sync] - before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer_end test_sync] + before_action :set_tournament, only: %i[update destroy set_timer timer sync_state test_sync] + before_action :authenticate_user!, only: %i[create update destroy set_timer test_sync] + before_action -> { require_owner! @tournament.owner }, only: %i[update destroy set_timer test_sync] before_action :validate_create_params, only: %i[create] before_action :validate_update_params, only: %i[update] - before_action :validate_set_timer_end_params, only: %i[set_timer_end] + before_action :validate_set_timer_params, only: %i[set_timer] before_action :ensure_update_allowed!, only: %i[update] before_action :ensure_destroy_allowed!, only: %i[destroy] - before_action :ensure_timer_update_allowed!, only: %i[set_timer_end] + before_action :ensure_timer_update_allowed!, only: %i[set_timer] before_action :authorize_sync_request!, only: %i[sync_state] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error @@ -129,14 +129,14 @@ class TournamentsController < ApplicationController @tournament.destroy end - # GET /tournaments/:id/timer_end - def timer_end + # GET /tournaments/:id/timer + def timer render json: serialize_timer_state(@tournament) end - # PATCH /tournaments/:id/set_timer_end - def set_timer_end - if @tournament.update(timer_end_params) + # PATCH /tournaments/:id/timer + def set_timer + if @tournament.update(timer_params) push_sync_if_needed!(@tournament) render json: @tournament else @@ -171,9 +171,9 @@ class TournamentsController < ApplicationController private - def timer_end_params + def timer_params { - timer_end: params[:timer_end] || params[:timestamp], + timer_end: params[:timer_end], timer_mode: params[:timer_mode] } end @@ -332,15 +332,12 @@ class TournamentsController < ApplicationController TournamentSyncEnqueue.call(tournament) end - def validate_set_timer_end_params + def validate_set_timer_params timestamp = params[:timestamp] timestamp_seconds = params[:timestamp_seconds] timer_mode = params[:timer_mode] clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) - timestamp = params[:timer_end] if timestamp.blank? && params[:timer_end].present? - timestamp_seconds = params[:timer_end_seconds] if timestamp_seconds.blank? && params[:timer_end_seconds].present? - if clear_timer if timestamp.present? || timestamp_seconds.present? || timer_mode.present? return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' }, diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 4e1af83..3108d5e 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -67,7 +67,7 @@ class TournamentSnapshotImporter def update_tournament! root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) - root_attrs[:timer_end] = parse_time(snapshot[:timestamp] || snapshot[:timer_end]) + root_attrs[:timer_end] = parse_time(snapshot[:timestamp]) root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at diff --git a/config/routes.rb b/config/routes.rb index 9c53ca4..7ef1030 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,8 +31,8 @@ Rails.application.routes.draw do end end member do - get :timer_end - patch :set_timer_end + get :timer + patch :timer, action: :set_timer patch :sync_state post :test_sync end diff --git a/doc/timer_api.md b/doc/timer_api.md index 3f170fd..dcb1169 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -24,7 +24,7 @@ Timer state is available in normal tournament payload: Dedicated timer endpoint also exists: -- `GET /tournaments/:id/timer_end` +- `GET /tournaments/:id/timer` Response shape: @@ -95,10 +95,7 @@ Follower tournaments in `read_only_mode` reject writes with `423`. Endpoint: -- `PATCH /tournaments/:id/set_timer_end` - -Current route name still says `set_timer_end`. -Payload field name is now `timestamp`. +- `PATCH /tournaments/:id/timer` ### Start countdown by duration diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index 9ad0682..eb67787 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -29,7 +29,7 @@ module TurniereE2E } timer_response = patch!( - "/tournaments/#{tournament.fetch(:id)}/set_timer_end", + "/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 600 } ) checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) } diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 9ebbca2..dd263ca 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -220,12 +220,12 @@ RSpec.describe TournamentsController, type: :controller do end end - describe 'GET #timer_end' do + describe 'GET #timer' do it 'returns timer state in ISO 8601 format' do timer_end = Time.utc(2026, 4, 13, 18, 0, 0) @tournament.update!(timer_end: timer_end, timer_mode: 'countdown') - get :timer_end, params: { id: @tournament.to_param } + get :timer, params: { id: @tournament.to_param } expect(response).to be_successful expect(deserialize_response(response)[:timestamp]).to eq(timer_end.iso8601) @@ -233,7 +233,7 @@ RSpec.describe TournamentsController, type: :controller do end end - describe 'PATCH #set_timer_end' do + describe 'PATCH #set_timer' do before(:each) do apply_authentication_headers_for @user end @@ -241,7 +241,7 @@ RSpec.describe TournamentsController, type: :controller do it 'stores timezone-aware timestamp values without shifting them' do timestamp = '2030-04-13T18:00:00+02:00' - patch :set_timer_end, params: { id: @tournament.to_param, timestamp: timestamp } + patch :set_timer, params: { id: @tournament.to_param, timestamp: timestamp } expect(response).to be_successful expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) @@ -252,7 +252,7 @@ RSpec.describe TournamentsController, type: :controller do it 'starts countup timer at current time when timer_mode=countup' do before_request = Time.zone.now - patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup' } + patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup' } expect(response).to be_successful expect(@tournament.reload.timer_mode).to eq('countup') @@ -263,7 +263,7 @@ RSpec.describe TournamentsController, type: :controller do it 'clears timer state when clear_timer=true' do @tournament.update!(timer_end: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') - patch :set_timer_end, params: { id: @tournament.to_param, clear_timer: true } + patch :set_timer, params: { id: @tournament.to_param, clear_timer: true } expect(response).to be_successful expect(@tournament.reload.timer_end).to be_nil @@ -273,7 +273,7 @@ RSpec.describe TournamentsController, type: :controller do end it 'rejects timestamp_seconds for countup mode' do - patch :set_timer_end, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } + patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 } expect(response).to have_http_status(:unprocessable_entity) expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode') @@ -742,7 +742,7 @@ RSpec.describe TournamentsController, type: :controller do playoff_teams_amount: 4, instant_finalists_amount: 4, intermediate_round_participants_amount: 0, - timer_end: nil, + timestamp: nil, teams: [{ id: 7, name: 'Alpha' }], stages: [], groups: [], diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 7db4ad6..aebfcaf 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -125,21 +125,21 @@ RSpec.describe 'Backend API surface HTTP E2E' do forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' }) unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' }) - timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) - timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer_end") - timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) - timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) - timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { + timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) + timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") + timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) + timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) + timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120, timestamp: (Time.now.utc + 240).iso8601 }) - timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 'nope' }) - timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 0 }) - timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { + timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 'nope' }) + timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 }) + timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup', timestamp_seconds: 60 }) - timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 60 }) + timer_forbidden = other_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 60 }) destroy_response = owner_client.delete("/tournaments/#{tournament.fetch(:id)}") after_destroy = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}") diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb index c8e43db..3b8c364 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -41,19 +41,19 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil - countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) + countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) expect(countdown[:status]).to eq(200) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) - countup = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timer_mode: 'countup' }) expect(countup[:status]).to eq(200) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) - clear = source_client.patch("/tournaments/#{source.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true }) expect(clear[:status]).to eq(200) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index f9d423e..1431476 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -32,19 +32,19 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil - countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timestamp_seconds: 120 }) + countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) expect(countdown[:status]).to eq(200) countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) - countup = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { timer_mode: 'countup' }) + countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) expect(countup[:status]).to eq(200) countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) - clear = client.patch("/tournaments/#{tournament.fetch(:id)}/set_timer_end", body: { clear_timer: true }) + clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) expect(clear[:status]).to eq(200) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) From e9ef73c2302452b5c7d38fffcf5bb1a36a64917c Mon Sep 17 00:00:00 2001 From: Malaber Date: Fri, 24 Apr 2026 11:39:26 +0200 Subject: [PATCH 5/5] refactor: rename timer db column --- app/controllers/tournaments_controller.rb | 12 +- app/models/tournament.rb | 2 +- app/serializers/tournament_serializer.rb | 2 +- app/services/tournament_snapshot_builder.rb | 2 +- app/services/tournament_snapshot_importer.rb | 2 +- app/services/tournament_sync_schema.rb | 2 +- .../tournament_timer_broadcast_payload.rb | 2 +- ...e_timer_end_to_timestamp_on_tournaments.rb | 7 + db/schema.rb | 223 ++++++++++++++++++ .../tournaments_controller_spec.rb | 16 +- .../tournament_snapshot_importer_spec.rb | 2 +- 11 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 db/migrate/20260424120000_rename_timer_end_to_timestamp_on_tournaments.rb create mode 100644 db/schema.rb diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index d2e4233..5fe10f0 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -173,14 +173,14 @@ class TournamentsController < ApplicationController def timer_params { - timer_end: params[:timer_end], + timestamp: params[:timestamp], timer_mode: params[:timer_mode] } end def serialize_timer_state(tournament) { - timestamp: tournament.timer_end&.iso8601, + timestamp: tournament.timestamp&.iso8601, timer_mode: tournament.timer_mode } end @@ -344,7 +344,7 @@ class TournamentsController < ApplicationController status: :unprocessable_entity end - params[:timer_end] = nil + params[:timestamp] = nil params[:timer_mode] = nil return end @@ -372,7 +372,7 @@ class TournamentsController < ApplicationController return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0 parsed_time = Time.zone.now + timestamp_seconds - params[:timer_end] = parsed_time + params[:timestamp] = parsed_time params[:timer_mode] = 'countdown' elsif timestamp.present? begin @@ -383,13 +383,13 @@ class TournamentsController < ApplicationController return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity end - params[:timer_end] = parsed_time + params[:timestamp] = parsed_time params[:timer_mode] = timer_mode.presence || 'countdown' rescue ArgumentError return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity end elsif timer_mode == 'countup' - params[:timer_end] = Time.zone.now + params[:timestamp] = Time.zone.now elsif timer_mode.present? return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, status: :unprocessable_entity diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 653f738..65c22d0 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -96,7 +96,7 @@ class Tournament < ApplicationRecord end def saved_change_to_timer_state? - saved_change_to_timer_end? || saved_change_to_timer_mode? + saved_change_to_timestamp? || saved_change_to_timer_mode? end def broadcast_timer_state_change diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index e5d525a..46009a1 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -12,7 +12,7 @@ class TournamentSerializer < SimpleTournamentSerializer has_many :team_action_lists attribute :timestamp do - object.timer_end&.iso8601 + object.timestamp&.iso8601 end attribute :timer_mode diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 9932cc8..8ca4a26 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -22,7 +22,7 @@ class TournamentSnapshotBuilder def build payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) - payload[:timestamp] = tournament.timer_end&.iso8601 + payload[:timestamp] = tournament.timestamp&.iso8601 payload[:timer_mode] = tournament.timer_mode payload[:sync_generated_at] = Time.current.iso8601(6) payload[:teams] = teams_payload diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index 3108d5e..d03beba 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -67,7 +67,7 @@ class TournamentSnapshotImporter def update_tournament! root_attrs = snapshot.slice(*ROOT_ATTRIBUTES) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) - root_attrs[:timer_end] = parse_time(snapshot[:timestamp]) + root_attrs[:timestamp] = parse_time(snapshot[:timestamp]) root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index bf03726..3d85a2d 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -12,7 +12,7 @@ class TournamentSyncSchema playoff_teams_amount instant_finalists_amount intermediate_round_participants_amount - timer_end + timestamp timer_mode ].freeze, ignored: %w[ diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb index 97fa866..9b790de 100644 --- a/app/services/tournament_timer_broadcast_payload.rb +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -6,7 +6,7 @@ class TournamentTimerBroadcastPayload { type: 'timer.updated', tournament_id: tournament.id, - timestamp: tournament.timer_end&.iso8601, + timestamp: tournament.timestamp&.iso8601, timer_mode: tournament.timer_mode } end diff --git a/db/migrate/20260424120000_rename_timer_end_to_timestamp_on_tournaments.rb b/db/migrate/20260424120000_rename_timer_end_to_timestamp_on_tournaments.rb new file mode 100644 index 0000000..d02680c --- /dev/null +++ b/db/migrate/20260424120000_rename_timer_end_to_timestamp_on_tournaments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RenameTimerEndToTimestampOnTournaments < ActiveRecord::Migration[7.0] + def change + rename_column :tournaments, :timer_end, :timestamp + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..656b26b --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,223 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 2026_04_24_120000) do + create_table "beamers", force: :cascade do |t| + t.string "name", null: false + t.string "display_state", default: "matches_all", null: false + t.boolean "is_default", default: false + t.string "qr_code_url" + t.string "logo_url" + t.integer "tournament_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "sync_source_id" + t.index ["tournament_id", "name"], name: "index_beamers_on_tournament_id_and_name", unique: true + t.index ["tournament_id"], name: "index_beamers_on_tournament_id" + end + + create_table "bets", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "match_id", null: false + t.integer "team_id" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["match_id"], name: "index_bets_on_match_id" + t.index ["team_id"], name: "index_bets_on_team_id" + t.index ["user_id"], name: "index_bets_on_user_id" + end + + create_table "group_scores", force: :cascade do |t| + t.integer "group_points", default: 0 + t.integer "scored_points", default: 0 + t.integer "received_points", default: 0 + t.integer "team_id", null: false + t.integer "group_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "position", default: 0 + t.integer "sync_source_id" + t.index ["group_id"], name: "index_group_scores_on_group_id" + t.index ["team_id"], name: "index_group_scores_on_team_id" + end + + create_table "groups", force: :cascade do |t| + t.integer "number" + t.integer "stage_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "sync_source_id" + t.index ["stage_id"], name: "index_groups_on_stage_id" + end + + create_table "match_scores", force: :cascade do |t| + t.integer "points", default: 0 + t.integer "match_id", null: false + t.integer "team_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "hidden_points", default: 0, null: false + t.integer "sync_source_id" + t.index ["match_id"], name: "index_match_scores_on_match_id" + t.index ["team_id"], name: "index_match_scores_on_team_id" + end + + create_table "matches", force: :cascade do |t| + t.integer "state", default: 0 + t.integer "position" + t.integer "stage_id" + t.integer "group_id" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.boolean "decider", default: false, null: false + t.integer "sync_source_id" + t.index ["group_id"], name: "index_matches_on_group_id" + t.index ["stage_id"], name: "index_matches_on_stage_id" + end + + create_table "stages", force: :cascade do |t| + t.integer "level" + t.integer "state", default: 0 + t.integer "tournament_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "sync_source_id" + t.index ["tournament_id"], name: "index_stages_on_tournament_id" + end + + create_table "team_action_items", force: :cascade do |t| + t.integer "team_action_list_id", null: false + t.integer "team_id", null: false + t.boolean "completed", default: false, null: false + t.datetime "completed_at" + t.integer "sync_source_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["sync_source_id"], name: "index_team_action_items_on_sync_source_id" + t.index ["team_action_list_id", "team_id"], name: "index_team_action_items_on_team_action_list_id_and_team_id", unique: true + t.index ["team_action_list_id"], name: "index_team_action_items_on_team_action_list_id" + t.index ["team_id"], name: "index_team_action_items_on_team_id" + end + + create_table "team_action_lists", force: :cascade do |t| + t.integer "tournament_id", null: false + t.string "name", null: false + t.string "key", null: false + t.string "action_name", null: false + t.string "source", null: false + t.integer "sync_source_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["sync_source_id"], name: "index_team_action_lists_on_sync_source_id" + t.index ["tournament_id", "key"], name: "index_team_action_lists_on_tournament_id_and_key", unique: true + t.index ["tournament_id"], name: "index_team_action_lists_on_tournament_id" + end + + create_table "teams", force: :cascade do |t| + t.string "name" + t.integer "tournament_id" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "sync_source_id" + t.index ["tournament_id"], name: "index_teams_on_tournament_id" + end + + create_table "tournament_sync_queue_entries", force: :cascade do |t| + t.integer "tournament_id", null: false + t.text "snapshot_json", null: false + t.datetime "next_attempt_at", null: false + t.datetime "last_attempt_at" + t.datetime "locked_at" + t.string "lock_token" + t.integer "attempts", default: 0, null: false + t.string "status", default: "pending", null: false + t.string "last_error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["next_attempt_at"], name: "index_tournament_sync_queue_entries_on_next_attempt_at" + t.index ["status"], name: "index_tournament_sync_queue_entries_on_status" + t.index ["tournament_id"], name: "index_tournament_sync_queue_entries_on_tournament_id", unique: true + end + + create_table "tournaments", force: :cascade do |t| + t.string "name", null: false + t.string "code", null: false + t.string "description" + t.boolean "public", default: true + t.integer "playoff_teams_amount", default: 0 + t.integer "instant_finalists_amount", default: 0 + t.integer "intermediate_round_participants_amount", default: 0 + t.integer "user_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "timestamp" + t.boolean "read_only_mode", default: false, null: false + t.string "sync_target_url" + t.string "sync_auth_token" + t.integer "sync_source_tournament_id" + t.datetime "sync_last_pushed_at" + t.string "sync_last_push_error" + t.datetime "sync_last_imported_snapshot_at" + t.string "timer_mode" + t.index ["code"], name: "index_tournaments_on_code", unique: true + t.index ["user_id"], name: "index_tournaments_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "provider", default: "email", null: false + t.string "uid", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at", precision: nil + t.boolean "allow_password_change", default: false + t.datetime "remember_created_at", precision: nil + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at", precision: nil + t.datetime "last_sign_in_at", precision: nil + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.string "confirmation_token" + t.datetime "confirmed_at", precision: nil + t.datetime "confirmation_sent_at", precision: nil + t.string "unconfirmed_email" + t.string "username" + t.string "email" + t.text "tokens" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["provider"], name: "index_users_on_provider" + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["uid"], name: "index_users_on_uid", unique: true + t.index ["username"], name: "index_users_on_username", unique: true + end + + add_foreign_key "beamers", "tournaments", on_delete: :cascade + add_foreign_key "bets", "matches", on_delete: :cascade + add_foreign_key "bets", "teams", on_delete: :cascade + add_foreign_key "bets", "users", on_delete: :cascade + add_foreign_key "group_scores", "groups", on_delete: :cascade + add_foreign_key "group_scores", "teams", on_delete: :cascade + add_foreign_key "groups", "stages", on_delete: :cascade + add_foreign_key "match_scores", "matches", on_delete: :cascade + add_foreign_key "match_scores", "teams", on_delete: :cascade + add_foreign_key "matches", "groups", on_delete: :cascade + add_foreign_key "matches", "stages", on_delete: :cascade + add_foreign_key "stages", "tournaments", on_delete: :cascade + add_foreign_key "team_action_items", "team_action_lists", on_delete: :cascade + add_foreign_key "team_action_items", "teams", on_delete: :cascade + add_foreign_key "team_action_lists", "tournaments", on_delete: :cascade + add_foreign_key "teams", "tournaments", on_delete: :cascade + add_foreign_key "tournament_sync_queue_entries", "tournaments", on_delete: :cascade + add_foreign_key "tournaments", "users", on_delete: :cascade +end diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index dd263ca..6ba0ed7 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -222,13 +222,13 @@ RSpec.describe TournamentsController, type: :controller do describe 'GET #timer' do it 'returns timer state in ISO 8601 format' do - timer_end = Time.utc(2026, 4, 13, 18, 0, 0) - @tournament.update!(timer_end: timer_end, timer_mode: 'countdown') + timestamp = Time.utc(2026, 4, 13, 18, 0, 0) + @tournament.update!(timestamp: timestamp, timer_mode: 'countdown') get :timer, params: { id: @tournament.to_param } expect(response).to be_successful - expect(deserialize_response(response)[:timestamp]).to eq(timer_end.iso8601) + expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end end @@ -244,8 +244,8 @@ RSpec.describe TournamentsController, type: :controller do patch :set_timer, params: { id: @tournament.to_param, timestamp: timestamp } expect(response).to be_successful - expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) - expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timer_end.iso8601) + expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) + expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601) expect(deserialize_response(response)[:timer_mode]).to eq('countdown') end @@ -256,17 +256,17 @@ RSpec.describe TournamentsController, type: :controller do expect(response).to be_successful expect(@tournament.reload.timer_mode).to eq('countup') - expect(@tournament.timer_end).to be_between(before_request - 1.second, Time.zone.now + 1.second) + expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second) expect(deserialize_response(response)[:timer_mode]).to eq('countup') end it 'clears timer state when clear_timer=true' do - @tournament.update!(timer_end: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') + @tournament.update!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') patch :set_timer, params: { id: @tournament.to_param, clear_timer: true } expect(response).to be_successful - expect(@tournament.reload.timer_end).to be_nil + expect(@tournament.reload.timestamp).to be_nil expect(@tournament.timer_mode).to be_nil expect(deserialize_response(response)[:timestamp]).to be_nil expect(deserialize_response(response)[:timer_mode]).to be_nil diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 4c9d1ef..29292ee 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -183,7 +183,7 @@ RSpec.describe TournamentSnapshotImporter do playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, - timestamp: tournament.timer_end&.iso8601, + timestamp: tournament.timestamp&.iso8601, timer_mode: tournament.timer_mode, teams: tournament.teams.order(:name).map { |team| team.name }, team_action_lists: tournament.team_action_lists.order(:key).map do |list|